opf-br-mcp 0.8.0 → 0.9.3

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.
Files changed (3) hide show
  1. package/README.md +9 -2
  2. package/dist/index.js +106 -34
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -39,7 +39,9 @@ acesso token-eficiente às regras do Open Finance Brasil.
39
39
  - `list_domains()` — descoberta: domínios, filtros, versão da spec de origem e estado do cache
40
40
  - `search(domain, query?, filters?, limit?, offset?)` — busca filtrada, retorno compacto
41
41
  - `get_item(domain, id)` — registro completo
42
- - `refresh(domain?)` — força re-extração das fontes
42
+ - `refresh(domain?)` — força re-extração das fontes. Prefira passar `domain`:
43
+ sem ele o server atualiza o que couber em 45s (o timeout padrão do cliente MCP
44
+ é 60s) e devolve o restante em `pendentes`, para o agente retomar um a um
43
45
 
44
46
  Fluxo recomendado para o agente: `list_domains` → `search` → `get_item`.
45
47
 
@@ -67,7 +69,12 @@ bem na janela de contexto de um agente, e despejá-la desperdiça tokens. A solu
67
69
  é **revelação progressiva** — o agente nunca recebe a spec completa de uma vez,
68
70
  apenas o mínimo necessário em cada etapa do funil:
69
71
 
70
- 1. **`list_domains`** — catálogo barato: quais domínios e filtros existem.
72
+ 1. **`list_domains`** — catálogo barato: quais domínios e filtros existem. Os
73
+ filtros idênticos a uma família inteira de domínios (os 8 `*-openapi`, os 11
74
+ de seções do Confluence) saem uma única vez em `filterSets`; cada domínio
75
+ traz `filterSet` e só lista inline o que é próprio dele (em `*-openapi`,
76
+ apenas `path`, cujo exemplo varia por API). Os filtros aceitos por um domínio
77
+ são a união dos dois.
71
78
  2. **`search`** — índice **pesquisável e resumido**. Cada resultado traz só os
72
79
  campos leves (`id`, `type`, `path`, `method`, `summary`/`name`, `required`,
73
80
  `in`); o nó pesado da spec (`detail`) e a lista `refs` são removidos do
package/dist/index.js CHANGED
@@ -18,14 +18,24 @@ function cacheDir() {
18
18
  function cachePath(domainId) {
19
19
  return join(cacheDir(), `${domainId}.json`);
20
20
  }
21
+ function isCacheEntry(value) {
22
+ const entry = value;
23
+ return typeof entry === "object" && entry !== null && typeof entry.extractedAt === "string" && typeof entry.packageVersion === "string" && typeof entry.data === "object" && entry.data !== null && Array.isArray(entry.data.items);
24
+ }
21
25
  function readCache(domainId) {
22
26
  const path = cachePath(domainId);
23
27
  if (!existsSync(path)) return null;
28
+ let parsed;
24
29
  try {
25
- return JSON.parse(readFileSync(path, "utf8"));
30
+ parsed = JSON.parse(readFileSync(path, "utf8"));
26
31
  } catch {
27
32
  return null;
28
33
  }
34
+ if (!isCacheEntry(parsed)) {
35
+ console.error(`[opf-br-mcp] cache de ${domainId} tem forma inesperada; ignorando (${path})`);
36
+ return null;
37
+ }
38
+ return parsed;
29
39
  }
30
40
  function writeCache(domainId, data, packageVersion) {
31
41
  const entry = {
@@ -46,7 +56,7 @@ function isFresh(entry, ttlHours, now = /* @__PURE__ */ new Date()) {
46
56
  }
47
57
 
48
58
  // src/core/version.ts
49
- var PACKAGE_VERSION = "0.8.0";
59
+ var PACKAGE_VERSION = "0.9.3";
50
60
 
51
61
  // src/core/data.ts
52
62
  async function getDomainData(domain, force = false, ctx) {
@@ -66,15 +76,52 @@ async function getDomainData(domain, force = false, ctx) {
66
76
  }
67
77
  }
68
78
 
79
+ // src/core/filter-sets.ts
80
+ var FILTER_SETS = {
81
+ openapi: [
82
+ { name: "method", description: "Verbo HTTP exato (ex.: POST)" },
83
+ { name: "schema", description: "Substring no nome do schema (case-insensitive)" },
84
+ {
85
+ name: "response",
86
+ description: "Substring no nome do components.responses, a chave usada no $ref (ex.: 202Webhook)"
87
+ },
88
+ {
89
+ name: "parameter",
90
+ description: "Substring no nome do components.parameters, a chave usada no $ref (ex.: xFapiInteractionId)"
91
+ },
92
+ {
93
+ name: "header",
94
+ description: "Substring no nome do components.headers, a chave usada no $ref (ex.: X-V)"
95
+ }
96
+ ],
97
+ sections: [
98
+ { name: "page", description: "Substring no t\xEDtulo da p\xE1gina Confluence" },
99
+ { name: "heading", description: "Substring no t\xEDtulo da se\xE7\xE3o (heading)" },
100
+ { name: "contains", description: "Substring no conte\xFAdo da se\xE7\xE3o" }
101
+ ]
102
+ };
103
+
69
104
  // src/core/http.ts
70
- function sleep(ms) {
71
- return new Promise((resolve) => setTimeout(resolve, ms));
105
+ function sleep(ms, signal) {
106
+ if (signal?.aborted) return Promise.resolve();
107
+ return new Promise((resolve) => {
108
+ const done = () => {
109
+ clearTimeout(timer);
110
+ signal?.removeEventListener("abort", done);
111
+ resolve();
112
+ };
113
+ const timer = setTimeout(done, ms);
114
+ signal?.addEventListener("abort", done, { once: true });
115
+ });
72
116
  }
73
117
  async function fetchWithRetry(url, opts) {
74
118
  let lastError;
75
119
  for (let attempt = 0; attempt <= opts.retryDelaysMs.length; attempt++) {
76
120
  if (opts.signal?.aborted) break;
77
- if (attempt > 0) await sleep(opts.retryDelaysMs[attempt - 1]);
121
+ if (attempt > 0) {
122
+ await sleep(opts.retryDelaysMs[attempt - 1], opts.signal);
123
+ if (opts.signal?.aborted) break;
124
+ }
78
125
  const timeout = AbortSignal.timeout(3e4);
79
126
  const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
80
127
  let response;
@@ -134,8 +181,14 @@ async function fetchConfluencePage(baseUrl, pageId, retryDelaysMs, signal) {
134
181
  const apiUrl = `${baseUrl}/wiki/rest/api/content/${pageId}?expand=body.view`;
135
182
  const response = await fetchWithRetry(apiUrl, { retryDelaysMs, headers: HEADERS, signal });
136
183
  const json = await response.json();
184
+ const html = json?.body?.view?.value;
185
+ if (typeof html !== "string") {
186
+ throw new Error(
187
+ `resposta da p\xE1gina ${pageId} n\xE3o traz body.view.value; n\xE3o \xE9 conte\xFAdo Confluence expandido`
188
+ );
189
+ }
137
190
  return {
138
- html: json.body?.view?.value ?? "",
191
+ html,
139
192
  title: json.title,
140
193
  url: `${baseUrl}/wiki${json._links?.webui ?? ""}`
141
194
  };
@@ -310,8 +363,8 @@ var pcmDomain = {
310
363
  const total = pcmConfig.pages.length;
311
364
  const pages = [];
312
365
  for (const [i, page] of pcmConfig.pages.entries()) {
366
+ if (i > 0) await sleep(pcmConfig.interRequestDelayMs, ctx?.signal);
313
367
  if (ctx?.signal?.aborted) throw new Error("Extra\xE7\xE3o cancelada pelo cliente");
314
- if (i > 0) await sleep(pcmConfig.interRequestDelayMs);
315
368
  ctx?.onProgress?.(i, total, `Extraindo "${page.title}"`);
316
369
  const { html, url } = await fetchConfluencePage(
317
370
  pcmConfig.confluenceBaseUrl,
@@ -380,7 +433,13 @@ function refsOf(node, specName) {
380
433
  return [...out];
381
434
  }
382
435
  function parseOpenApiSpec(yamlText, specName) {
383
- const spec = parse(yamlText);
436
+ const parsed = parse(yamlText);
437
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed) || !("paths" in parsed) && !("components" in parsed)) {
438
+ throw new Error(
439
+ `resposta de ${specName} n\xE3o \xE9 uma spec OpenAPI (sem "paths" nem "components"); in\xEDcio do conte\xFAdo: ${JSON.stringify(yamlText.slice(0, 80))}`
440
+ );
441
+ }
442
+ const spec = parsed;
384
443
  const items = [];
385
444
  for (const [path, pathItem] of Object.entries(spec.paths ?? {})) {
386
445
  for (const method of HTTP_METHODS) {
@@ -435,22 +494,10 @@ function createOpenApiDomain(config) {
435
494
  description: config.description,
436
495
  specVersion: config.specVersion,
437
496
  ttlHours: 72,
497
+ filterSet: "openapi",
438
498
  filters: [
439
499
  { name: "path", description: `Substring no path do endpoint (ex.: ${config.pathExample})` },
440
- { name: "method", description: "Verbo HTTP exato (ex.: POST)" },
441
- { name: "schema", description: "Substring no nome do schema (case-insensitive)" },
442
- {
443
- name: "response",
444
- description: "Substring no nome do components.responses, a chave usada no $ref (ex.: 202Webhook)"
445
- },
446
- {
447
- name: "parameter",
448
- description: "Substring no nome do components.parameters, a chave usada no $ref (ex.: xFapiInteractionId)"
449
- },
450
- {
451
- name: "header",
452
- description: "Substring no nome do components.headers, a chave usada no $ref (ex.: X-V)"
453
- }
500
+ ...FILTER_SETS.openapi
454
501
  ],
455
502
  async extract(ctx) {
456
503
  if (ctx?.signal?.aborted) throw new Error("Extra\xE7\xE3o cancelada pelo cliente");
@@ -592,17 +639,14 @@ function createConfluenceSectionsDomain(config) {
592
639
  description: config.description,
593
640
  specVersion: config.specVersion,
594
641
  ttlHours: 72,
595
- filters: [
596
- { name: "page", description: "Substring no t\xEDtulo da p\xE1gina Confluence" },
597
- { name: "heading", description: "Substring no t\xEDtulo da se\xE7\xE3o (heading)" },
598
- { name: "contains", description: "Substring no conte\xFAdo da se\xE7\xE3o" }
599
- ],
642
+ filterSet: "sections",
643
+ filters: [...FILTER_SETS.sections],
600
644
  async extract(ctx) {
601
645
  const total = config.pages.length;
602
646
  const pages = [];
603
647
  for (const [i, page] of config.pages.entries()) {
648
+ if (i > 0) await sleep(config.interRequestDelayMs, ctx?.signal);
604
649
  if (ctx?.signal?.aborted) throw new Error("Extra\xE7\xE3o cancelada pelo cliente");
605
- if (i > 0) await sleep(config.interRequestDelayMs);
606
650
  ctx?.onProgress?.(i, total, `Extraindo "${page.title}"`);
607
651
  const { html, url } = await fetchConfluencePage(
608
652
  config.confluenceBaseUrl,
@@ -1000,6 +1044,11 @@ var participantesConfig = {
1000
1044
  // src/domains/participantes/parser.ts
1001
1045
  function parseParticipants(jsonText) {
1002
1046
  const orgs = JSON.parse(jsonText);
1047
+ if (!Array.isArray(orgs) || orgs.length === 0) {
1048
+ throw new Error(
1049
+ `resposta do diret\xF3rio n\xE3o \xE9 uma lista de organiza\xE7\xF5es n\xE3o-vazia; in\xEDcio do conte\xFAdo: ${JSON.stringify(jsonText.slice(0, 80))}`
1050
+ );
1051
+ }
1003
1052
  return orgs.map((org) => {
1004
1053
  const servers = org.AuthorisationServers ?? [];
1005
1054
  const families = /* @__PURE__ */ new Set();
@@ -1181,6 +1230,7 @@ function findDomain(id) {
1181
1230
  return domains.find((d) => d.id === id);
1182
1231
  }
1183
1232
  var validIds = () => domains.map((d) => d.id).join(", ");
1233
+ var REFRESH_BUDGET_MS = 45e3;
1184
1234
  var domainIdSchema = z.enum(domains.map((d) => d.id)).describe("Id do dom\xEDnio (ver list_domains)");
1185
1235
  function extractContext(extra) {
1186
1236
  const progressToken = extra._meta?.progressToken;
@@ -1195,7 +1245,7 @@ function extractContext(extra) {
1195
1245
  }
1196
1246
  };
1197
1247
  }
1198
- function createServer() {
1248
+ function createServer(refreshBudgetMs = REFRESH_BUDGET_MS) {
1199
1249
  const server2 = new McpServer(
1200
1250
  { name: "opf-br-mcp", version: PACKAGE_VERSION },
1201
1251
  {
@@ -1206,7 +1256,7 @@ function createServer() {
1206
1256
  "list_domains",
1207
1257
  {
1208
1258
  title: "Listar dom\xEDnios",
1209
- description: "Lista os dom\xEDnios de conhecimento do Open Finance Brasil dispon\xEDveis neste server, com os filtros aceitos por cada um, a vers\xE3o da spec de origem e o estado do cache local. Devolve tamb\xE9m a vers\xE3o deste server em `server.version`. Comece por aqui; depois use search(domain, ...) e get_item(domain, id).",
1259
+ description: "Lista os dom\xEDnios de conhecimento do Open Finance Brasil dispon\xEDveis neste server, com os filtros aceitos por cada um, a vers\xE3o da spec de origem e o estado do cache local. Devolve tamb\xE9m a vers\xE3o deste server em `server.version`. Os filtros aceitos por um dom\xEDnio s\xE3o a uni\xE3o de `filters` (os pr\xF3prios dele, pode vir ausente) com `filterSets[<filterSet>]` (o conjunto comum \xE0 fam\xEDlia, listado uma vez no topo). Comece por aqui; depois use search(domain, ...) e get_item(domain, id).",
1210
1260
  annotations: {
1211
1261
  readOnlyHint: true,
1212
1262
  destructiveHint: false,
@@ -1216,11 +1266,14 @@ function createServer() {
1216
1266
  },
1217
1267
  async () => {
1218
1268
  const out = domains.map((d) => {
1269
+ const shared = d.filterSet ? FILTER_SETS[d.filterSet] : void 0;
1270
+ const own = shared ? d.filters.filter((f) => !shared.some((s) => s.name === f.name && s.description === f.description)) : d.filters;
1219
1271
  const base = {
1220
1272
  id: d.id,
1221
1273
  title: d.title,
1222
1274
  description: d.description,
1223
- filters: d.filters,
1275
+ ...d.filterSet ? { filterSet: d.filterSet } : {},
1276
+ ...own.length > 0 ? { filters: own } : {},
1224
1277
  ...d.specVersion ? { specVersion: d.specVersion } : {}
1225
1278
  };
1226
1279
  if (d.live) {
@@ -1233,7 +1286,14 @@ function createServer() {
1233
1286
  extractedAt: cached?.extractedAt ?? null
1234
1287
  };
1235
1288
  });
1236
- return text({ server: { name: "opf-br-mcp", version: PACKAGE_VERSION }, domains: out });
1289
+ const usedSets = Object.fromEntries(
1290
+ Object.entries(FILTER_SETS).filter(([name]) => domains.some((d) => d.filterSet === name))
1291
+ );
1292
+ return text({
1293
+ server: { name: "opf-br-mcp", version: PACKAGE_VERSION },
1294
+ filterSets: usedSets,
1295
+ domains: out
1296
+ });
1237
1297
  }
1238
1298
  );
1239
1299
  server2.registerTool(
@@ -1349,7 +1409,7 @@ function createServer() {
1349
1409
  "refresh",
1350
1410
  {
1351
1411
  title: "Re-extrair fontes",
1352
- description: "For\xE7a re-extra\xE7\xE3o das fontes p\xFAblicas (ignora o TTL de 72h do cache). Sem `domain`, atualiza todos. Use quando suspeitar de dados desatualizados.",
1412
+ description: "For\xE7a re-extra\xE7\xE3o das fontes p\xFAblicas (ignora o TTL de 72h do cache). Use quando suspeitar de dados desatualizados. Prefira passar `domain`: sem ele o server atualiza o que couber em 45s e devolve o restante em `pendentes`, que voc\xEA deve refazer chamando refresh(domain) para cada id.",
1353
1413
  inputSchema: {
1354
1414
  domain: domainIdSchema.optional().describe("Id do dom\xEDnio; omita para todos")
1355
1415
  },
@@ -1365,7 +1425,13 @@ function createServer() {
1365
1425
  (d) => !d.live && (!domain || d.id === domain)
1366
1426
  );
1367
1427
  const report = {};
1428
+ const pendentes = [];
1429
+ const inicio = Date.now();
1368
1430
  for (const d of targets) {
1431
+ if (!domain && Date.now() - inicio > refreshBudgetMs) {
1432
+ pendentes.push(d.id);
1433
+ continue;
1434
+ }
1369
1435
  try {
1370
1436
  const { data } = await getDomainData(d, true, extractContext(extra));
1371
1437
  report[d.id] = `ok: ${data.items.length} itens`;
@@ -1373,7 +1439,13 @@ function createServer() {
1373
1439
  report[d.id] = `erro: ${err.message}`;
1374
1440
  }
1375
1441
  }
1376
- return text(report);
1442
+ return text({
1443
+ atualizados: report,
1444
+ ...pendentes.length > 0 ? {
1445
+ pendentes,
1446
+ nota: `Parei em ${refreshBudgetMs / 1e3}s para n\xE3o estourar o timeout do cliente. Chame refresh(domain) para cada id em pendentes.`
1447
+ } : {}
1448
+ });
1377
1449
  }
1378
1450
  );
1379
1451
  return server2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opf-br-mcp",
3
- "version": "0.8.0",
3
+ "version": "0.9.3",
4
4
  "description": "MCP server local com as regras do Open Finance Brasil (PCM additionalInfo, specs OpenAPI de Pagamentos)",
5
5
  "type": "module",
6
6
  "license": "MIT",