senado-br-mcp 3.18.1 → 3.18.2

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
@@ -564,13 +564,13 @@ Fields of the `concise` block (per response — one tool, one source; keys in th
564
564
  | `citation` | Ready-to-use citation string (human-readable) |
565
565
  | `license` | Source terms (Dados Abertos do Senado Federal) |
566
566
 
567
- The canonical model behind the block also carries `dataset.id` (item/series identifier, e.g. `codigoMateria=137808`), `api_version` and per-field `field_sources`; those are validated on every build and inform `attribution` (below), but are not part of the `concise` projection.
567
+ The canonical model behind the block also carries `dataset.id` (item/series identifier, e.g. `codigoMateria=137808`) and `api_version`; those are validated on every build and inform `attribution` (below), but are not part of the `concise` projection. Per-field `field_sources` are (contract v1.2, since 3.17.0), on the responses that merge reads.
568
568
 
569
569
  In addition to the `provenance` envelope, `structuredContent` carries a top-level **`attribution`** list — the distinct source URLs behind the response. This mirrors the naming proposed in [modelcontextprotocol#711](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/711) (where `attribution` is a list of source references at the response level), so the server stays forward-compatible if that RFC lands; the richer `provenance` object remains this server's own extension.
570
570
 
571
571
  **Out-of-band mirror in `_meta`.** The same `provenance` and `attribution` are also mirrored on the result's `_meta` under namespaced keys (`com.sidneybissoli.senado/provenance` and `com.sidneybissoli.senado/attribution`). The MCP spec keeps `_meta` for metadata *about* a result that should not steer the model, which is where the still-incubating trust/attribution work from #711 (split into an experimental extensions track, not yet in core) points; mirroring there gives audit and UI consumers the provenance without reading the model-facing data channel, at no model-token cost, while `structuredContent` keeps it visible so the model can cite the source. The mirror survives the ChatGPT-app profile minimizer (which only strips `structuredContent.meta`).
572
572
 
573
- **Field-level granularity.** Most tools are single-source, so one envelope suffices. The few that merge slices in one response fill `field_sources` in the canonical model — a list of `{ fields, source_url, data_vintage, retrieved_at, … }` attributing specific output fields to their real origin. Example: `senado_obter_materia` `secao=detalhe` fuses `/processo/{id}` (the top-level source) with the `ementa` from `/processo` and the `relator` from `/processo/relatoria`, each carrying its own `retrieved_at`. In the emitted `concise` block the per-field detail is summarized through `attribution`, which always lists every distinct underlying `source_url`.
573
+ **Field-level granularity.** Most tools are single-source, so one envelope suffices. The few that merge reads in one response fill `field_sources` — a list of `{ fields, source_url, dataset_id, data_vintage, retrieved_at, served_from_cache }` attributing specific output fields to their real origin, and the block's `retrieved_at` is the oldest of them. `served_from_cache` is `true`/`false` for reads through the server cache and `null` where the notion does not apply (the bundled org-chart snapshot, the e-Cidadania corpus in D1). The merging tools (since 3.18.2): `senado_obter_materia` `secao=detalhe` (`/processo/{id}` + `ementa` from `/processo` + `relator` from `/processo/relatoria`), `senado_obter_senador` and the Deep Research `fetch` of a senator (biography + `mandatos`/`emExercicio` from `/senador/{codigo}/mandatos`), `senado_reunioes_comissao` over a window that crosses the year (one read per year), `senado_servidores` with `subordinadasA` (payroll + org-chart snapshot), `senado_suprimento_fundos` statistics on `atos-concessao` (acts + beneficiary names from the supridos registry), `senado_ecidadania_obter_evento` (live detail + `comentarios` from the corpus) and `senado_ecidadania_sugerir_tema_enquete` (consultas and ideias corpora). **Only a read that becomes a field of the answer is a sub-source**: a read that only resolves or checks an identifier (a committee acronym → its code, the parent check in `senado_contratacao_detalhe`) is left out, otherwise a cached lookup table would make the whole answer claim to be as old as it. `attribution` always lists every distinct underlying `source_url`.
574
574
 
575
575
  `retrieved_at` fidelity is provided by the cache layer (`cachedFetchWithMeta`), which persists the fetch timestamp alongside the value, so it reflects the real upstream extraction even on a cache hit. Two exceptions report an honest live timestamp instead: the e-Cidadania **list** tools (read from D1) use the corpus's `lastScrapedAt` — the true age of the stored data — while e-Cidadania **detail** tools, scraped live, use the fetch time and a level-3 canonical item URL. The only path that falls back to the build-time default is the in-code static reference catalog (`senado_tabelas_referencia` `tipos-materia`), which has no upstream extraction instant.
576
576
 
@@ -17,7 +17,7 @@ import { upstreamFetch, UpstreamError } from "../throttle/upstream.js";
17
17
  import { toolError, errorFrom, ensureArray, safeInt, toBool, normalizeText } from "../utils/validation.js";
18
18
  import { digArrayRoot } from "../utils/upstream-parse.js";
19
19
  import { escolherReuniao, ehReuniaoInexistente, mensagemCodigoInexistente } from "./resolver-reuniao.js";
20
- import { provenanceFor, resultWithProvenance } from "../utils/provenance.js";
20
+ import { leituraDoCache, provenanceFor, resultWithProvenance } from "../utils/provenance.js";
21
21
  import { CACHE_SEMI_STATIC, CACHE_DYNAMIC, CACHE_ON_DEMAND } from "../types.js";
22
22
  export function formatDateYMD(d) {
23
23
  return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
@@ -125,6 +125,35 @@ export async function fetchComissaoColegiado(codigo, baseUrl) {
125
125
  const colegiado = ensureArray(r?.ComissoesCongressoNacional?.Colegiados?.Colegiado)[0];
126
126
  return { path, fetchedAt, colegiado };
127
127
  }
128
+ /**
129
+ * Proveniência de `senado_reunioes_comissao`. A fonte não aceita janela que cruza o ano, então
130
+ * a tool lê um recorte por ano, cada um numa entrada de cache própria, e junta as reuniões.
131
+ * Com um recorte só, o bloco é o desse recorte. Com dois, cada um vira sub-fonte das MESMAS
132
+ * linhas (`reunioes`, `count`) com a sua janela e o seu instante, e o topo é o mais antigo
133
+ * (adaptador, contrato §3) — até a 3.18.1 o topo era sempre o do 1º recorte e o instante do
134
+ * 2º era descartado, mesmo quando ele estava em cache havia mais tempo.
135
+ */
136
+ export function provenanceReunioesComissao(baseUrl, sigla, di, df, partes) {
137
+ const dataset_id = `comissao=${sigla}`;
138
+ const [primeira] = partes;
139
+ const base = baseUrl.replace(/\/$/, "");
140
+ return provenanceFor("SENADO_LEGIS", baseUrl, `/comissao/agenda/${di}/${df}`, {
141
+ dataset_id,
142
+ reference_period: `${di}/${df}`,
143
+ retrieved_at: primeira.fetchedAt,
144
+ ...(partes.length > 1
145
+ ? {
146
+ field_sources: partes.map((p) => ({
147
+ fields: ["reunioes", "count"],
148
+ source_url: `${base}/comissao/agenda/${p.dataInicio}/${p.dataFim}`,
149
+ dataset_id,
150
+ reference_period: `${p.dataInicio}/${p.dataFim}`,
151
+ ...leituraDoCache(p),
152
+ })),
153
+ }
154
+ : {}),
155
+ });
156
+ }
128
157
  export function registerComissoesTools(server, baseUrl) {
129
158
  // E1. senado_listar_comissoes
130
159
  server.tool("senado_listar_comissoes", "Lista comissões (colegiados) ativas do Senado, com filtros por `tipo` (permanente, temporaria, cpi, mista) e `ativa`. Retorna `{ count, comissoes }`, cada item com `codigo`, `sigla`, `nome`, `tipo`, `casa` e `ativa`. O endpoint só traz comissões ativas, logo `ativa=false` resulta em lista vazia. Use para descobrir a `sigla` exigida por `senado_obter_comissao` e `senado_reunioes_comissao`.", {
@@ -229,17 +258,20 @@ export function registerComissoesTools(server, baseUrl) {
229
258
  const yearDi = parseInt(di.slice(0, 4));
230
259
  const yearDf = parseInt(df.slice(0, 4));
231
260
  let todas = [];
232
- let fetchedAt;
261
+ let partes;
233
262
  if (yearDi === yearDf) {
234
263
  const res = await cachedFetchWithMeta("senado_reunioes_comissao", { sigla, dataInicio: di, dataFim: df }, CACHE_DYNAMIC, () => upstreamFetch(`/comissao/agenda/${di}/${df}`, {}, baseUrl));
235
- fetchedAt = res.fetchedAt;
264
+ partes = [{ dataInicio: di, dataFim: df, ...res }];
236
265
  todas = ensureArray(res.value?.AgendaReuniao?.reunioes?.reuniao);
237
266
  }
238
267
  else {
239
268
  // Split: [di..yearDi-12-31] + [yearDf-01-01..df]
240
269
  const r1 = await cachedFetchWithMeta("senado_reunioes_comissao_p1", { sigla, dataInicio: di, dataFim: `${yearDi}1231` }, CACHE_DYNAMIC, () => upstreamFetch(`/comissao/agenda/${di}/${yearDi}1231`, {}, baseUrl));
241
270
  const r2 = await cachedFetchWithMeta("senado_reunioes_comissao_p2", { sigla, dataInicio: `${yearDf}0101`, dataFim: df }, CACHE_DYNAMIC, () => upstreamFetch(`/comissao/agenda/${yearDf}0101/${df}`, {}, baseUrl));
242
- fetchedAt = r1.fetchedAt;
271
+ partes = [
272
+ { dataInicio: di, dataFim: `${yearDi}1231`, ...r1 },
273
+ { dataInicio: `${yearDf}0101`, dataFim: df, ...r2 },
274
+ ];
243
275
  todas = [
244
276
  ...ensureArray(r1.value?.AgendaReuniao?.reunioes?.reuniao),
245
277
  ...ensureArray(r2.value?.AgendaReuniao?.reunioes?.reuniao),
@@ -259,11 +291,7 @@ export function registerComissoesTools(server, baseUrl) {
259
291
  tipo: re.tipo?.descricao || null,
260
292
  situacao: re.situacao || null,
261
293
  }));
262
- const prov = provenanceFor("SENADO_LEGIS", baseUrl, `/comissao/agenda/${di}/${df}`, {
263
- dataset_id: `comissao=${sigla}`,
264
- reference_period: `${di}/${df}`,
265
- retrieved_at: fetchedAt,
266
- });
294
+ const prov = provenanceReunioesComissao(baseUrl, sigla, di, df, partes);
267
295
  return resultWithProvenance({ sigla, periodo: { dataInicio: di, dataFim: df }, count: reunioes.length, reunioes }, prov);
268
296
  }
269
297
  catch (e) {
@@ -37,7 +37,7 @@ import { upstreamFetch } from "../throttle/upstream.js";
37
37
  import { ensureArray } from "../utils/validation.js";
38
38
  import { provenanceExtras, provenanceFor } from "../utils/provenance.js";
39
39
  import { CACHE_SEMI_STATIC } from "../types.js";
40
- import { extractParlamentares, fetchSenadorDetalhe, parseSenadorResumo } from "./senadores.js";
40
+ import { extractParlamentares, fetchSenadorDetalhe, parseSenadorResumo, provenanceSenadorDetalhe } from "./senadores.js";
41
41
  import { RootNotFoundError } from "../utils/upstream-parse.js";
42
42
  import { classifyThrown } from "../call-shape.js";
43
43
  import { fetchComissaoColegiado, parseComissaoItem, parseComissaoResumo } from "./comissoes.js";
@@ -214,13 +214,10 @@ function handlers(baseUrl) {
214
214
  return null;
215
215
  throw e;
216
216
  }
217
- const { path, fetchedAt, detalhe } = detalheSenador;
217
+ const { detalhe } = detalheSenador;
218
218
  if (!detalhe.codigo)
219
219
  return null;
220
- const prov = provenanceFor("SENADO_LEGIS", baseUrl, path, {
221
- dataset_id: `codigoParlamentar=${codigo}`,
222
- retrieved_at: fetchedAt,
223
- });
220
+ const prov = provenanceSenadorDetalhe(baseUrl, codigo, detalheSenador);
224
221
  return {
225
222
  document: {
226
223
  id,
@@ -12,7 +12,7 @@
12
12
  import { z } from "zod";
13
13
  import { cachedFetchWithMeta } from "../cache/manager.js";
14
14
  import { errorFrom } from "../utils/validation.js";
15
- import { provenanceEcidadania, provenanceArquimedesVotos, resultWithProvenance } from "../utils/provenance.js";
15
+ import { leituraDoCache, provenanceEcidadania, provenanceArquimedesVotos, resultWithProvenance, withFieldSources, } from "../utils/provenance.js";
16
16
  import { tagUntrustedFields, tagUntrustedList, neutralizeUntrustedText, sanitizeScrapedText } from "../utils/untrusted.js";
17
17
  import { CACHE_ON_DEMAND } from "../types.js";
18
18
  import { resolveList, writeDetalheThrough } from "../scraper/store.js";
@@ -46,6 +46,68 @@ export const OBTER_CONSULTA_DATAS_AVISO = "O portal e-Cidadania não publica o p
46
46
  export function buildConsultaDetalheResult(r) {
47
47
  return { ...r, comentarios: null, aviso: OBTER_CONSULTA_DATAS_AVISO };
48
48
  }
49
+ /** URL canônica do item raspado (nível 3) quando o scraper a expõe; senão o caminho de fallback. */
50
+ function urlDoItem(item, fallbackPath) {
51
+ const url = item?.url;
52
+ return typeof url === "string" && url.startsWith("http") ? url : fallbackPath;
53
+ }
54
+ /**
55
+ * Sub-fonte lida do corpus em D1 (`resolveList`): o instante é o `lastScrapedAt` da meta, o
56
+ * mesmo que `provLista` põe no topo das listas; o D1 não é cache, então `served_from_cache`
57
+ * fica de fora (sai `null`).
58
+ */
59
+ export function subFonteDoCorpus(pathOrUrl, dataset_id, meta, fields) {
60
+ const ts = meta?.lastScrapedAt;
61
+ return {
62
+ fields,
63
+ source_url: provenanceEcidadania(pathOrUrl).source_url,
64
+ dataset_id,
65
+ ...(typeof ts === "string" && ts ? { retrieved_at: ts } : {}),
66
+ };
67
+ }
68
+ /**
69
+ * Proveniência de `senado_ecidadania_obter_evento`. O detalhe é raspado ao vivo (cache próprio),
70
+ * mas a página estática não lê a contagem de `comentarios` (carregada por AJAX): ela vem do
71
+ * corpus em D1, lido noutro instante. Até a 3.18.1 o topo era só o do detalhe; agora, quando o
72
+ * corpus dá a contagem, as duas leituras viram sub-fontes e o topo é o mais antigo (adaptador,
73
+ * contrato §3). Evento fora do corpus = `comentarios: null`, sem sub-fonte.
74
+ */
75
+ export function provenanceEventoDetalhe(item, id, leitura, corpusMeta) {
76
+ const dataset_id = `evento=${id}`;
77
+ const prov = provenanceEcidadania(urlDoItem(item, `/visualizacaoaudiencia?id=${id}`), {
78
+ dataset_id,
79
+ retrieved_at: leitura.fetchedAt,
80
+ });
81
+ if (corpusMeta === undefined)
82
+ return prov;
83
+ return withFieldSources(prov, [
84
+ {
85
+ fields: Object.keys(item).filter((k) => k !== "comentarios"),
86
+ source_url: prov.source_url,
87
+ dataset_id,
88
+ ...leituraDoCache(leitura),
89
+ },
90
+ subFonteDoCorpus("/principalaudiencia", "eventos", corpusMeta, ["comentarios"]),
91
+ ]);
92
+ }
93
+ /**
94
+ * Proveniência de `senado_ecidadania_sugerir_tema_enquete`: as sugestões cruzam DOIS corpora
95
+ * do D1 (consultas e ideias), cada um com o seu `lastScrapedAt`. Até a 3.18.1 o topo levava só
96
+ * o das consultas e o das ideias era descartado; agora cada corpus é sub-fonte das mesmas
97
+ * contagens e o topo é o mais antigo (adaptador, contrato §3).
98
+ */
99
+ export function provenanceSugestaoEnquete(consultasMeta, ideiasMeta) {
100
+ const campos = ["sugestoes", "count", "totalAnalisados", "totalQualificados"];
101
+ const ts = consultasMeta?.lastScrapedAt;
102
+ const prov = provenanceEcidadania("/principalmateria", {
103
+ dataset_id: "consultas+ideias",
104
+ retrieved_at: typeof ts === "string" && ts ? ts : undefined,
105
+ });
106
+ return withFieldSources(prov, [
107
+ subFonteDoCorpus("/principalmateria", "consultas", consultasMeta, campos),
108
+ subFonteDoCorpus("/principalideia", "ideias", ideiasMeta, campos),
109
+ ]);
110
+ }
49
111
  // ══════════════════════════════════════════════════════════════════════════
50
112
  // Tool registration
51
113
  // ══════════════════════════════════════════════════════════════════════════
@@ -74,9 +136,7 @@ export function registerECidadaniaTools(server, _baseUrl, env, ctx) {
74
136
  // Detalhes são raspados ao vivo (via cachedFetch): use a URL canônica do próprio item quando
75
137
  // o scraper a expõe (nível 3), com o fetchedAt do cache como retrieved_at.
76
138
  function provDetalhe(item, fallbackPath, dataset_id, fetchedAt) {
77
- const url = item?.url;
78
- const alvo = typeof url === "string" && url.startsWith("http") ? url : fallbackPath;
79
- return provenanceEcidadania(alvo, { dataset_id, retrieved_at: fetchedAt });
139
+ return provenanceEcidadania(urlDoItem(item, fallbackPath), { dataset_id, retrieved_at: fetchedAt });
80
140
  }
81
141
  function ecidadaniaError(e) {
82
142
  // Mesmo caminho de toda tool (errorFrom: log cru, classe pelo TIPO da exceção via
@@ -266,22 +326,26 @@ export function registerECidadaniaTools(server, _baseUrl, env, ctx) {
266
326
  // G9. senado_ecidadania_obter_evento
267
327
  server.tool("senado_ecidadania_obter_evento", "Obtém o detalhe completo de um evento interativo do e-Cidadania (audiência, sabatina, live). Retorna um objeto com `id`, `titulo`, `descricao`, `data`, `hora`, `comissao` e `comissaoNomeCompleto`, `local`, `status` (`agendado`/`encerrado`/`cancelado`), `comentarios`, `url`, mais `pauta` (até 15 itens), `convidados` e `videoUrl` (embed do YouTube quando houver, senão `null`) — campos não preenchidos vêm `null` e `id` inexistente retorna erro. Obtenha o `id` antes via `senado_ecidadania_listar_eventos`. Para apenas listar/rankear eventos (sem descrição/pauta/convidados) use `senado_ecidadania_listar_eventos`, não esta.", { id: z.number().int().positive().describe("Identificador do evento (campo `id` de senado_ecidadania_listar_eventos)") }, async (params) => {
268
328
  try {
269
- const { value: r, fetchedAt } = await cachedFetchWithMeta("ecidadania_evento", { id: params.id }, CACHE_ON_DEMAND, () => obterEventoInternal(params.id));
329
+ const leitura = await cachedFetchWithMeta("ecidadania_evento", { id: params.id }, CACHE_ON_DEMAND, () => obterEventoInternal(params.id));
330
+ const r = leitura.value;
270
331
  writeDetalheThrough(db, ctx, "eventos", params.id, r);
271
332
  // The static detail scrape can't read the AJAX-loaded comentarios count (always 0);
272
333
  // the corpus carries the canonical count from the AJAX fragment (estudo A3 — the
273
334
  // listing count is spurious, 0 in ~82%). Splice it in, or null when the event is not
274
335
  // in the corpus (unknown, not a spurious 0).
275
336
  const detalhe = { ...r };
337
+ let corpusMeta;
276
338
  try {
277
- const { items } = await resolveList(db, "eventos", corpusStaleMaxMin(), () => listarEventosInternal({ limite: 100 }), undefined, CORPUS_RESOLVE);
339
+ const { items, meta } = await resolveList(db, "eventos", corpusStaleMaxMin(), () => listarEventosInternal({ limite: 100 }), undefined, CORPUS_RESOLVE);
278
340
  const corpusItem = items.find((e) => e.id === params.id);
279
341
  detalhe.comentarios = corpusItem ? corpusItem.comentarios : null;
342
+ if (corpusItem)
343
+ corpusMeta = meta;
280
344
  }
281
345
  catch {
282
346
  detalhe.comentarios = null;
283
347
  }
284
- return resultWithProvenance(tagUntrustedFields("eventos", detalhe), provDetalhe(r, `/visualizacaoaudiencia?id=${params.id}`, `evento=${params.id}`, fetchedAt));
348
+ return resultWithProvenance(tagUntrustedFields("eventos", detalhe), provenanceEventoDetalhe(r, params.id, leitura, corpusMeta));
285
349
  }
286
350
  catch (e) {
287
351
  return ecidadaniaError(e);
@@ -352,7 +416,7 @@ export function registerECidadaniaTools(server, _baseUrl, env, ctx) {
352
416
  totalQualificados: sugestoes.length,
353
417
  sugestoes: retornadas,
354
418
  meta: { consultas: cRes.meta, ideias: iRes.meta },
355
- }, provLista("/principalmateria", "consultas+ideias", cRes.meta));
419
+ }, provenanceSugestaoEnquete(cRes.meta, iRes.meta));
356
420
  }
357
421
  catch (e) {
358
422
  return ecidadaniaError(e);
@@ -145,8 +145,15 @@ export function provenanceMateriaDetalhe(baseUrl, a) {
145
145
  source_url: `${base}/processo/${a.idProcesso}`,
146
146
  dataset_id,
147
147
  retrieved_at: a.detalheFetchedAt,
148
+ served_from_cache: a.doCache?.detalhe,
149
+ },
150
+ {
151
+ fields: ["ementa"],
152
+ source_url: `${base}/processo`,
153
+ dataset_id,
154
+ retrieved_at: a.resumoFetchedAt,
155
+ served_from_cache: a.doCache?.resumo,
148
156
  },
149
- { fields: ["ementa"], source_url: `${base}/processo`, dataset_id, retrieved_at: a.resumoFetchedAt },
150
157
  ];
151
158
  if (a.relatoriaFetchedAt !== undefined) {
152
159
  fieldSources.push({
@@ -154,6 +161,7 @@ export function provenanceMateriaDetalhe(baseUrl, a) {
154
161
  source_url: `${base}/processo/relatoria`,
155
162
  dataset_id,
156
163
  retrieved_at: a.relatoriaFetchedAt,
164
+ served_from_cache: a.doCache?.relatoria,
157
165
  });
158
166
  }
159
167
  return provenanceFor("SENADO_LEGIS", baseUrl, `/processo/${a.idProcesso}`, {
@@ -164,12 +172,12 @@ export function provenanceMateriaDetalhe(baseUrl, a) {
164
172
  });
165
173
  }
166
174
  async function resolveProcesso(codigoMateria, baseUrl) {
167
- const { value: response, fetchedAt } = await cachedFetchWithMeta("_processo_por_materia", { codigoMateria }, CACHE_ON_DEMAND, () => upstreamFetch("/processo", { codigoMateria: String(codigoMateria) }, baseUrl));
175
+ const { value: response, fetchedAt, fromCache } = await cachedFetchWithMeta("_processo_por_materia", { codigoMateria }, CACHE_ON_DEMAND, () => upstreamFetch("/processo", { codigoMateria: String(codigoMateria) }, baseUrl));
168
176
  const item = ensureArray(response)[0];
169
177
  if (!item || !item.id) {
170
178
  throw new Error(`Matéria ${codigoMateria} não encontrada na API de processos`);
171
179
  }
172
- return { item, fetchedAt };
180
+ return { item, fetchedAt, fromCache };
173
181
  }
174
182
  /**
175
183
  * Zero resultado por palavra-chave não é prova de que o tema não existe: a
@@ -312,7 +320,7 @@ export function registerMateriasTools(server, baseUrl) {
312
320
  cachedFetchWithMeta("_processo_relatoria", { codigoMateria: params.codigoMateria }, CACHE_ON_DEMAND, () => upstreamFetch("/processo/relatoria", { codigoMateria: String(params.codigoMateria) }, baseUrl)).catch(() => null),
313
321
  ]);
314
322
  const resumo = resumoRes.item;
315
- const { value: detalheRes, fetchedAt } = await cachedFetchWithMeta("senado_obter_materia", { idProcesso: resumo.id }, CACHE_ON_DEMAND, () => upstreamFetch(`/processo/${resumo.id}`, {}, baseUrl));
323
+ const { value: detalheRes, fetchedAt, fromCache } = await cachedFetchWithMeta("senado_obter_materia", { idProcesso: resumo.id }, CACHE_ON_DEMAND, () => upstreamFetch(`/processo/${resumo.id}`, {}, baseUrl));
316
324
  const detalhe = parseProcessoDetalhe(detalheRes);
317
325
  const relator = pickRelatorAtual(ensureArray(relatoriasRes?.value));
318
326
  const prov = provenanceMateriaDetalhe(baseUrl, {
@@ -322,6 +330,7 @@ export function registerMateriasTools(server, baseUrl) {
322
330
  detalheFetchedAt: fetchedAt,
323
331
  resumoFetchedAt: resumoRes.fetchedAt,
324
332
  relatoriaFetchedAt: relator ? relatoriasRes?.fetchedAt : undefined,
333
+ doCache: { detalhe: fromCache, resumo: resumoRes.fromCache, relatoria: relatoriasRes?.fromCache },
325
334
  });
326
335
  return resultWithProvenance({
327
336
  ...detalhe,
@@ -11,7 +11,7 @@ import { cachedFetchWithMeta } from "../cache/manager.js";
11
11
  import { upstreamFetch } from "../throttle/upstream.js";
12
12
  import { errorFrom, ensureArray, normalizeText, safeInt } from "../utils/validation.js";
13
13
  import { digArrayRoot, digObjectRoot } from "../utils/upstream-parse.js";
14
- import { provenanceFor, resultWithProvenance, toBrasiliaIso } from "../utils/provenance.js";
14
+ import { leituraDoCache, provenanceFor, resultWithProvenance, toBrasiliaIso } from "../utils/provenance.js";
15
15
  import { CACHE_SEMI_STATIC, CACHE_DYNAMIC, CACHE_ON_DEMAND } from "../types.js";
16
16
  export function parseSenadorResumo(parlamentar) {
17
17
  const id = parlamentar.IdentificacaoParlamentar || parlamentar;
@@ -210,7 +210,7 @@ export function matchesNome(s, nome) {
210
210
  */
211
211
  export async function fetchSenadorDetalhe(codigoSenador, baseUrl) {
212
212
  const path = `/senador/${codigoSenador}`;
213
- const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_obter_senador", { codigo: codigoSenador }, CACHE_ON_DEMAND, () => upstreamFetch(path, {}, baseUrl));
213
+ const { value: response, fetchedAt, fromCache } = await cachedFetchWithMeta("senado_obter_senador", { codigo: codigoSenador }, CACHE_ON_DEMAND, () => upstreamFetch(path, {}, baseUrl));
214
214
  // Ausência com HTTP 200: para um código que não existe o upstream devolve
215
215
  // `{"DetalheParlamentar":{...Metadados}}` — envelope e metadados presentes,
216
216
  // SEM o nó `Parlamentar` (medido em 24/09/2026: 200, 304 bytes). O
@@ -228,10 +228,14 @@ export async function fetchSenadorDetalhe(codigoSenador, baseUrl) {
228
228
  // É também o único endpoint que carrega os `Exercicios`, de onde sai o
229
229
  // `emExercicio` — lista vazia (falha do sub-endpoint) vira `null`, não `false`.
230
230
  let mandatosBrutos = [];
231
+ // Leitura dos mandatos, só quando ela de fato deu os campos `mandatos`/`emExercicio`
232
+ // (falha = lista vazia e `null`, que não vêm de fonte nenhuma).
233
+ let mandatosLeitura;
231
234
  try {
232
235
  const mandatosPath = `/senador/${codigoSenador}/mandatos`;
233
- const { value: mResp } = await cachedFetchWithMeta("senado_obter_senador_mandatos", { codigo: codigoSenador }, CACHE_ON_DEMAND, () => upstreamFetch(mandatosPath, {}, baseUrl));
236
+ const { value: mResp, fetchedAt: mFetchedAt, fromCache: mFromCache } = await cachedFetchWithMeta("senado_obter_senador_mandatos", { codigo: codigoSenador }, CACHE_ON_DEMAND, () => upstreamFetch(mandatosPath, {}, baseUrl));
234
237
  mandatosBrutos = digArrayRoot(mResp, [["MandatoParlamentar", "Parlamentar", "Mandatos", "Mandato"]], "senado_obter_senador:mandatos");
238
+ mandatosLeitura = { fetchedAt: mFetchedAt, fromCache: mFromCache };
235
239
  }
236
240
  catch {
237
241
  // sub-endpoint unavailable — keep the (empty) mandatos from the detail
@@ -240,6 +244,8 @@ export async function fetchSenadorDetalhe(codigoSenador, baseUrl) {
240
244
  return {
241
245
  path,
242
246
  fetchedAt,
247
+ fromCache,
248
+ mandatosLeitura,
243
249
  detalhe: {
244
250
  ...detalhe,
245
251
  emExercicio: derivarEmExercicio(mandatosBrutos, hoje),
@@ -247,6 +253,42 @@ export async function fetchSenadorDetalhe(codigoSenador, baseUrl) {
247
253
  },
248
254
  };
249
255
  }
256
+ /** Campos do detalhe que vêm de `/senador/{codigo}/mandatos`, não do detalhe biográfico. */
257
+ const CAMPOS_DOS_MANDATOS = ["mandatos", "emExercicio"];
258
+ /**
259
+ * Proveniência de `senado_obter_senador` (e do `fetch` `senador:` do Deep Research, que
260
+ * renderiza o mesmo registro no texto do documento). O registro funde DUAS leituras, cada
261
+ * uma de uma entrada de cache própria: o detalhe biográfico (`/senador/{codigo}`, topo) e os
262
+ * mandatos (`/senador/{codigo}/mandatos`), de onde saem `mandatos` e `emExercicio`. Até a
263
+ * 3.18.1 o instante dos mandatos era descartado e o topo era o do detalhe — com os mandatos
264
+ * em cache desde antes, o bloco dizia que `emExercicio` fora lido mais recentemente do que
265
+ * foi. Com as duas como sub-fontes o topo passa a ser o mais antigo (adaptador, contrato §3).
266
+ * Mandatos que falharam não viram sub-fonte: a resposta leva `[]` e `null`, sem fonte.
267
+ */
268
+ export function provenanceSenadorDetalhe(baseUrl, codigoSenador, leitura) {
269
+ const dataset_id = `codigoParlamentar=${codigoSenador}`;
270
+ const topo = { dataset_id, retrieved_at: leitura.fetchedAt };
271
+ if (!leitura.mandatosLeitura)
272
+ return provenanceFor("SENADO_LEGIS", baseUrl, leitura.path, topo);
273
+ const base = baseUrl.replace(/\/$/, "");
274
+ return provenanceFor("SENADO_LEGIS", baseUrl, leitura.path, {
275
+ ...topo,
276
+ field_sources: [
277
+ {
278
+ fields: Object.keys(leitura.detalhe).filter((k) => !CAMPOS_DOS_MANDATOS.includes(k)),
279
+ source_url: `${base}${leitura.path}`,
280
+ dataset_id,
281
+ ...leituraDoCache(leitura),
282
+ },
283
+ {
284
+ fields: CAMPOS_DOS_MANDATOS,
285
+ source_url: `${base}${leitura.path}/mandatos`,
286
+ dataset_id,
287
+ ...leituraDoCache(leitura.mandatosLeitura),
288
+ },
289
+ ],
290
+ });
291
+ }
250
292
  export function registerSenadoresTools(server, baseUrl) {
251
293
  // A1. senado_listar_senadores (filtro `nome` substitui a antiga busca por nome)
252
294
  server.tool("senado_listar_senadores", "Use para pedidos como 'liste os senadores em exercício', 'senadores atuais', 'lista atual de senadores' ou filtros por UF/partido. Lista senadores em exercício ou de uma legislatura específica, com filtros opcionais por `nome`, `uf` e `partido`. Retorna `{ count, senadores }`, cada item com `codigo`, `nome`, `nomeCompleto`, `partido`, `uf`, `foto` e `emExercicio`, mais proveniência oficial do endpoint `/senador/lista/atual`. Use `emExercicio` (padrão `true`) ou `legislatura` para escolher o conjunto; `nome` faz correspondência parcial ignorando acentos/maiúsculas (use quando você só tem o nome e precisa do `codigo`); `uf`/`partido` filtram localmente. Use o `codigo` em `senado_obter_senador` ou `senado_votacoes_senador`. Para senadores fora de exercício veja `senado_senadores_afastados`.", {
@@ -287,12 +329,9 @@ export function registerSenadoresTools(server, baseUrl) {
287
329
  codigoSenador: z.number().int().positive().describe("Código único do senador no sistema do Senado"),
288
330
  }, async (params) => {
289
331
  try {
290
- const { path, fetchedAt, detalhe } = await fetchSenadorDetalhe(params.codigoSenador, baseUrl);
291
- const prov = provenanceFor("SENADO_LEGIS", baseUrl, path, {
292
- dataset_id: `codigoParlamentar=${params.codigoSenador}`,
293
- retrieved_at: fetchedAt,
294
- });
295
- return resultWithProvenance(detalhe, prov);
332
+ const leitura = await fetchSenadorDetalhe(params.codigoSenador, baseUrl);
333
+ const prov = provenanceSenadorDetalhe(baseUrl, params.codigoSenador, leitura);
334
+ return resultWithProvenance(leitura.detalhe, prov);
296
335
  }
297
336
  catch (e) {
298
337
  return errorFrom(e, "Senador não encontrado");
@@ -343,14 +343,18 @@ export function particionarPorUnidade(lista, indice, codUnidade) {
343
343
  * das duas (derivado pelo adaptador, contrato §3), e a folha entra como sub-fonte própria
344
344
  * para que o seu instante não suma do bloco. Antes da 3.17.0 o topo era o da folha, mais
345
345
  * novo que o snapshot — com a v1.2 ligada, a lib lançaria e a tool cairia.
346
+ *
347
+ * `folhaDoCache` é o `fromCache` da leitura da folha; o snapshot embutido não é cache nem
348
+ * busca, então a sub-fonte dele não diz (`served_from_cache: null`).
346
349
  */
347
- export function provenanceServidoresSubordinadas(prov, situacao) {
350
+ export function provenanceServidoresSubordinadas(prov, situacao, folhaDoCache) {
348
351
  return withFieldSources(prov, [
349
352
  {
350
353
  fields: ["servidores", "total", "count", "naoClassificados", "afastadosOuEmTransito"],
351
354
  source_url: prov.source_url,
352
355
  dataset_id: `servidores; situacao=${situacao}`,
353
356
  retrieved_at: prov.retrieved_at,
357
+ served_from_cache: folhaDoCache,
354
358
  },
355
359
  {
356
360
  fields: ["subordinadasA", "total", "naoClassificados"],
@@ -373,7 +377,7 @@ export function registerServidoresTools(server, admBaseUrl) {
373
377
  }, async (params) => {
374
378
  try {
375
379
  const situacao = params.situacao ?? "ativos";
376
- const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_servidores", { situacao }, CACHE_SEMI_STATIC, () => admFetchLarge(`/servidores/servidores/${situacao}`, {}, admBaseUrl));
380
+ const { value: response, fetchedAt, fromCache } = await cachedFetchWithMeta("senado_servidores", { situacao }, CACHE_SEMI_STATIC, () => admFetchLarge(`/servidores/servidores/${situacao}`, {}, admBaseUrl));
377
381
  let lista = ensureArray(response).map(parseServidor);
378
382
  if (params.nome)
379
383
  lista = lista.filter((s) => matchesFiltro(s.nome, params.nome));
@@ -398,7 +402,7 @@ export function registerServidoresTools(server, admBaseUrl) {
398
402
  return toolError(`Unidade '${params.subordinadasA}' não encontrada na estrutura organizacional.${dica}`, "nao_encontrado");
399
403
  }
400
404
  const { sob, naoClassificados, afastadosOuEmTransito } = particionarPorUnidade(lista, indice, alvo.cod);
401
- const provComEstrutura = provenanceServidoresSubordinadas(prov, situacao);
405
+ const provComEstrutura = provenanceServidoresSubordinadas(prov, situacao, fromCache);
402
406
  return resultWithProvenance({
403
407
  situacao,
404
408
  subordinadasA: { sigla: alvo.sigla, nome: alvo.nome },
@@ -9,7 +9,7 @@ import { z } from "zod";
9
9
  import { cachedFetchWithMeta } from "../cache/manager.js";
10
10
  import { admFetch } from "../throttle/adm.js";
11
11
  import { errorFrom, ensureArray } from "../utils/validation.js";
12
- import { provenanceFor, resultWithProvenance } from "../utils/provenance.js";
12
+ import { leituraDoCache, provenanceFor, resultWithProvenance, withFieldSources } from "../utils/provenance.js";
13
13
  import { computarEstatisticas, arredondarEstatisticas, arredondarEntradas, } from "../utils/estatisticas.js";
14
14
  import { CACHE_STATIC } from "../types.js";
15
15
  import { matchesFiltro } from "./contratacoes.js";
@@ -172,22 +172,66 @@ export function estatisticasSuprimento(lista, opts) {
172
172
  /**
173
173
  * Build a `codigo -> nome` map of supridos for a year, from the registry (tipo=supridos, ~1 MB),
174
174
  * so `atos-concessao` statistics can identify the beneficiary by NAME instead of the internal code.
175
- * Cached by year; a fetch failure degrades to an empty map (entries fall back to name = null).
175
+ * Cached by year; a fetch failure degrades to an empty map (entries fall back to name = null)
176
+ * and no `leitura` — the names then come from nowhere, so there is no sub-source to declare.
176
177
  */
177
178
  export async function nomesSupridos(ano, admBaseUrl) {
178
- const map = new Map();
179
+ const nomes = new Map();
179
180
  try {
180
- const { value } = await cachedFetchWithMeta("senado_suprimento_fundos_nomes", { ano }, CACHE_STATIC, () => admFetch(PATHS["supridos"](ano), {}, admBaseUrl));
181
+ const { value, fetchedAt, fromCache } = await cachedFetchWithMeta("senado_suprimento_fundos_nomes", { ano }, CACHE_STATIC, () => admFetch(PATHS["supridos"](ano), {}, admBaseUrl));
181
182
  for (const s of ensureArray(value)) {
182
183
  const cod = s?.codigo != null ? String(s.codigo) : null;
183
184
  if (cod && s?.nome)
184
- map.set(cod, String(s.nome));
185
+ nomes.set(cod, String(s.nome));
185
186
  }
187
+ return { nomes, leitura: { fetchedAt, fromCache } };
186
188
  }
187
189
  catch {
188
190
  // best-effort enrichment; the ranking still works without names
191
+ return { nomes };
189
192
  }
190
- return map;
193
+ }
194
+ /**
195
+ * Which fields of the `atos-concessao` statistics carry the suprido's NAME: the groups when
196
+ * grouping by suprido; nothing when grouping by another (valid) dimension; top/bottom otherwise
197
+ * (no grouping, or an invalid grouping that falls back to the row distribution).
198
+ */
199
+ export function camposComNomeDeSuprido(agruparPor) {
200
+ if (agruparPor === "suprido")
201
+ return ["grupos"];
202
+ if (agruparPor != null && CHAVES_POR_TIPO["atos-concessao"][agruparPor])
203
+ return [];
204
+ return ["top", "bottom"];
205
+ }
206
+ /**
207
+ * Proveniência de `senado_suprimento_fundos` com `estatisticas` em atos de concessão: os atos
208
+ * (`prov`, topo) são cruzados com o cadastro de supridos do ano (`/supridos/{ano}`, outra
209
+ * entrada de cache) para identificar cada beneficiário pelo NOME. Até a 3.18.1 o instante do
210
+ * cadastro era descartado. Agora, quando os nomes aparecem na resposta, os atos e o cadastro
211
+ * viram sub-fontes e o topo é o mais antigo (adaptador, contrato §3). Sem leitura do cadastro
212
+ * (falhou) ou sem campo com nome (agrupado por outra dimensão), o bloco fica o dos atos.
213
+ */
214
+ export function provenanceSuprimentoComNomes(prov, atos, ano, admBaseUrl, nomes, agruparPor) {
215
+ const campos = camposComNomeDeSuprido(agruparPor);
216
+ if (!nomes.leitura || campos.length === 0)
217
+ return prov;
218
+ return withFieldSources(prov, [
219
+ {
220
+ fields: agruparPor === "suprido" ? ["total", "totalGrupos", "grupos"] : ["total", "distribuicao", "top", "bottom"],
221
+ source_url: prov.source_url,
222
+ dataset_id: prov.dataset.id ?? undefined,
223
+ reference_period: String(ano),
224
+ retrieved_at: prov.retrieved_at,
225
+ served_from_cache: atos.fromCache,
226
+ },
227
+ {
228
+ fields: campos,
229
+ source_url: `${admBaseUrl.replace(/\/$/, "")}/api/v1${PATHS["supridos"](ano)}`,
230
+ dataset_id: `suprimento; tipo=supridos; ano=${ano}`,
231
+ reference_period: String(ano),
232
+ ...leituraDoCache(nomes.leitura),
233
+ },
234
+ ]);
191
235
  }
192
236
  export function registerSupridosTools(server, admBaseUrl) {
193
237
  // R1. senado_suprimento_fundos
@@ -209,7 +253,7 @@ export function registerSupridosTools(server, admBaseUrl) {
209
253
  try {
210
254
  const tipo = params.tipo ?? "supridos";
211
255
  const path = PATHS[tipo](params.ano);
212
- const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_suprimento_fundos", { ano: params.ano, tipo }, CACHE_STATIC, () => admFetch(path, {}, admBaseUrl));
256
+ const { value: response, fetchedAt, fromCache } = await cachedFetchWithMeta("senado_suprimento_fundos", { ano: params.ano, tipo }, CACHE_STATIC, () => admFetch(path, {}, admBaseUrl));
213
257
  let lista = ensureArray(response);
214
258
  if (params.filtro) {
215
259
  const f = params.filtro;
@@ -234,14 +278,14 @@ export function registerSupridosTools(server, admBaseUrl) {
234
278
  }, prov);
235
279
  }
236
280
  // atos-concessao: join the supridos registry so entries carry the beneficiary's NAME.
237
- const nomePorCodigo = tipo === "atos-concessao" ? await nomesSupridos(params.ano, admBaseUrl) : undefined;
281
+ const nomes = tipo === "atos-concessao" ? await nomesSupridos(params.ano, admBaseUrl) : undefined;
238
282
  return resultWithProvenance({
239
283
  ano: params.ano,
240
284
  tipo,
241
285
  modo: "estatisticas",
242
286
  total: lista.length,
243
- ...estatisticasSuprimento(lista, { tipo, campo: params.campo, agruparPor: params.agruparPor, topN: params.topN ?? 10, nomePorCodigo }),
244
- }, prov);
287
+ ...estatisticasSuprimento(lista, { tipo, campo: params.campo, agruparPor: params.agruparPor, topN: params.topN ?? 10, nomePorCodigo: nomes?.nomes }),
288
+ }, nomes ? provenanceSuprimentoComNomes(prov, { fromCache }, params.ano, admBaseUrl, nomes, params.agruparPor) : prov);
245
289
  }
246
290
  return resultWithProvenance({
247
291
  ano: params.ano,
@@ -78,6 +78,17 @@ const toCanonicalFieldSource = (fs) => ({
78
78
  dataset_id: fs.dataset_id ?? null,
79
79
  data_vintage: fs.reference_period ?? null,
80
80
  retrieved_at: fs.retrieved_at ?? null,
81
+ served_from_cache: fs.served_from_cache ?? null,
82
+ });
83
+ /**
84
+ * Instante e origem de uma leitura de `cachedFetchWithMeta`, nos nomes da sub-fonte:
85
+ * `{ retrieved_at, served_from_cache }`. Entrada de cache sem envelope (anterior ao
86
+ * `fetchedAt` persistido) volta com `fromCache: true` e o instante de AGORA — o `true`
87
+ * continua verdadeiro; o instante é o melhor que a camada sabe.
88
+ */
89
+ export const leituraDoCache = (m) => ({
90
+ retrieved_at: m.fetchedAt,
91
+ served_from_cache: m.fromCache,
81
92
  });
82
93
  /**
83
94
  * `retrieved_at` do topo de uma resposta que funde sub-fontes: o MAIS ANTIGO entre o
@@ -237,6 +248,14 @@ export function provenanceArquimedesVotos(extra) {
237
248
  * tools cuja resposta funde campos de mais de um endpoint upstream — o `source_url` de topo
238
249
  * cobre o grosso do payload e cada `FieldSource` atribui os campos específicos à sua origem.
239
250
  * O envelope é re-validado inteiro no contrato (build).
251
+ *
252
+ * REGRA (decisão do dono, 13/10/2026): só entra como sub-fonte a leitura que VIRA CAMPO da
253
+ * resposta. Leitura só de resolução ou checagem — sigla→código da comissão, o id do processo
254
+ * antes da tramitação, a confirmação do pai em `senado_contratacao_detalhe` — fica de fora.
255
+ * Se entrasse, a regra do topo = mais antigo faria a resposta inteira dizer "dados de ontem"
256
+ * porque a tabela de siglas está em cache desde ontem, com os campos lidos agora (aviso falso
257
+ * de desatualização). Custo aceito: uma tradução errada por cache velho (código recriado na
258
+ * fonte) não aparece no bloco — risco baixo, os códigos são estáveis.
240
259
  */
241
260
  export function withFieldSources(prov, fieldSources) {
242
261
  if (fieldSources.length === 0)
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  /** Single source of truth for the server version (kept in sync with package.json). */
2
- export const VERSION = "3.18.1";
2
+ export const VERSION = "3.18.2";
3
3
  export const USER_AGENT = `senado-br-mcp/${VERSION} (+https://senado.sidneybissoli.com; sbissoli76@gmail.com)`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "senado-br-mcp",
3
- "version": "3.18.1",
3
+ "version": "3.18.2",
4
4
  "description": "MCP server for Brazilian Senate open data — runs hosted on Cloudflare Workers or locally via npx (stdio)",
5
5
  "keywords": [
6
6
  "mcp",