next-finance-mcp 0.9.21 → 0.9.23

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/dist/client.d.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  * - O walletId é salvo na sessão para re-seleção automática ao restaurar.
10
10
  * - apiGet/apiPost detectam 500 "Erro desconhecido" e fazem auto-refresh da carteira.
11
11
  */
12
+ /** Persistência opcional: só faz sentido no modo stdio (1 usuário por máquina).
13
+ * No modo HTTP multi-tenant, o caller controla o lifecycle do contexto. */
12
14
  export declare function saveSession(): void;
13
15
  export declare function loadSession(): boolean;
14
16
  export declare function deleteSession(): void;
@@ -121,7 +123,10 @@ export declare function buscarComprasProduto(opts?: FiltroComprasProduto): Promi
121
123
  fornecedor: string;
122
124
  moeda: string;
123
125
  valor: number;
126
+ quantidade: number;
127
+ valor_total: number;
124
128
  data: string;
129
+ observacao: string;
125
130
  }[];
126
131
  paginacao: {
127
132
  pagina: number;
@@ -200,6 +205,9 @@ export interface ItemDespesaSlim {
200
205
  valor: number;
201
206
  desconto: number;
202
207
  valor_total: number;
208
+ valor_bruto?: number;
209
+ valor_total_bruto?: number;
210
+ desconto_aplicado?: number;
203
211
  }
204
212
  export interface DespesaSlim {
205
213
  data: string;
package/dist/client.js CHANGED
@@ -13,6 +13,7 @@ import * as fs from "fs";
13
13
  import * as path from "path";
14
14
  import * as os from "os";
15
15
  import { request as undiciRequest, Agent, interceptors } from "undici";
16
+ import { getCtx } from "./context.js";
16
17
  // Agentes pré-configurados para controle de redirects (undici v7 usa interceptors)
17
18
  const agentNoRedirect = new Agent().compose(interceptors.redirect({ maxRedirections: 0 }));
18
19
  const agentFollow = new Agent().compose(interceptors.redirect({ maxRedirections: 5 }));
@@ -20,13 +21,16 @@ const WEB_BASE = "https://finance.net.br"; // login e seleção de carteira (MVC
20
21
  const API_BASE = "https://api.finance.net.br"; // API REST (mesmo cookie de sessão)
21
22
  const SESSION_DIR = path.join(os.homedir(), ".next-finance-mcp");
22
23
  const SESSION_FILE = path.join(SESSION_DIR, "session.json");
24
+ /** Persistência opcional: só faz sentido no modo stdio (1 usuário por máquina).
25
+ * No modo HTTP multi-tenant, o caller controla o lifecycle do contexto. */
23
26
  export function saveSession() {
24
27
  try {
28
+ const ctx = getCtx();
25
29
  fs.mkdirSync(SESSION_DIR, { recursive: true });
26
30
  const data = {
27
- cookies: [...cookieStore],
31
+ cookies: [...ctx.cookies],
28
32
  savedAt: new Date().toISOString(),
29
- selectedWalletId: _selectedWallet ?? undefined,
33
+ selectedWalletId: ctx.selectedWallet ?? undefined,
30
34
  };
31
35
  fs.writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2), "utf-8");
32
36
  }
@@ -34,16 +38,17 @@ export function saveSession() {
34
38
  }
35
39
  export function loadSession() {
36
40
  try {
41
+ const ctx = getCtx();
37
42
  const raw = fs.readFileSync(SESSION_FILE, "utf-8");
38
43
  const data = JSON.parse(raw);
39
44
  // Aceita sessões salvas há menos de 3 dias
40
45
  const age = Date.now() - new Date(data.savedAt).getTime();
41
46
  if (age > 3 * 24 * 60 * 60 * 1000)
42
47
  return false;
43
- cookieStore.length = 0;
44
- cookieStore.push(...data.cookies);
48
+ ctx.cookies.length = 0;
49
+ ctx.cookies.push(...data.cookies);
45
50
  if (data.selectedWalletId)
46
- _selectedWallet = data.selectedWalletId;
51
+ ctx.selectedWallet = data.selectedWalletId;
47
52
  return true;
48
53
  }
49
54
  catch {
@@ -56,8 +61,9 @@ export function deleteSession() {
56
61
  }
57
62
  catch { /* ignora */ }
58
63
  }
59
- const cookieStore = [];
64
+ // ── Cookie store (por contexto) ─────────────────────────────────────────────
60
65
  function storeCookies(setCookieHeaders, domain) {
66
+ const cookies = getCtx().cookies;
61
67
  for (const header of setCookieHeaders) {
62
68
  const pair = header.split(";")[0].trim();
63
69
  const eqIdx = pair.indexOf("=");
@@ -65,21 +71,21 @@ function storeCookies(setCookieHeaders, domain) {
65
71
  continue;
66
72
  const name = pair.slice(0, eqIdx).trim();
67
73
  const value = pair.slice(eqIdx + 1).trim();
68
- const existing = cookieStore.findIndex(c => c.name === name && c.domain === domain);
74
+ const existing = cookies.findIndex(c => c.name === name && c.domain === domain);
69
75
  if (existing >= 0)
70
- cookieStore[existing].value = value;
76
+ cookies[existing].value = value;
71
77
  else
72
- cookieStore.push({ name, value, domain });
78
+ cookies.push({ name, value, domain });
73
79
  }
74
80
  }
75
81
  function getCookieHeader(domain) {
76
- return cookieStore
82
+ return getCtx().cookies
77
83
  .filter(c => domain.includes(c.domain) || c.domain.includes(domain))
78
84
  .map(c => `${c.name}=${c.value}`)
79
85
  .join("; ");
80
86
  }
81
87
  function clearCookies() {
82
- cookieStore.length = 0;
88
+ getCtx().cookies.length = 0;
83
89
  }
84
90
  // ── Helpers de request ─────────────────────────────────────────────────────
85
91
  const BASE_HEADERS = {
@@ -153,14 +159,15 @@ function isCarteiraTokenExpired(status, text) {
153
159
  }
154
160
  /** Re-seleciona a carteira para renovar o token (c=) expirado. */
155
161
  async function refreshCarteiraToken() {
156
- if (!_selectedWallet)
162
+ const idCarteira = getCtx().selectedWallet;
163
+ if (!idCarteira)
157
164
  return false;
158
165
  try {
159
166
  const cartPage = await webGet("/Carteira");
160
167
  const csrf = extractCsrf(cartPage.text);
161
168
  if (!csrf)
162
169
  return false;
163
- const formBody = new URLSearchParams({ __RequestVerificationToken: csrf, idCarteira: _selectedWallet }).toString();
170
+ const formBody = new URLSearchParams({ __RequestVerificationToken: csrf, idCarteira }).toString();
164
171
  const resp = await webPost("/Carteira", formBody, "application/x-www-form-urlencoded", false);
165
172
  if (resp.status === 302 || resp.status === 200) {
166
173
  saveSession();
@@ -171,8 +178,8 @@ async function refreshCarteiraToken() {
171
178
  return false;
172
179
  }
173
180
  function throwSessionExpired() {
174
- _loggedIn = false;
175
- _selectedWallet = null;
181
+ getCtx().loggedIn = false;
182
+ getCtx().selectedWallet = null;
176
183
  clearCookies();
177
184
  throw new Error("Sessão expirada. Use a ferramenta `login` para autenticar novamente.");
178
185
  }
@@ -265,21 +272,19 @@ async function apiPost(path, body, retry = true) {
265
272
  function extractCsrf(html) {
266
273
  return html.match(/name="__RequestVerificationToken"[^>]+value="([^"]+)"/)?.[1] ?? "";
267
274
  }
268
- // ── Estado da sessão ────────────────────────────────────────────────────────
269
- let _loggedIn = false;
270
- let _selectedWallet = null;
271
- export function isAuthenticated() { return _loggedIn; }
272
- export function getSelectedWallet() { return _selectedWallet; }
275
+ // ── Estado da sessão (delegado ao ClientContext) ────────────────────────────
276
+ export function isAuthenticated() { return getCtx().loggedIn; }
277
+ export function getSelectedWallet() { return getCtx().selectedWallet; }
273
278
  export function logout() {
274
279
  clearCookies();
275
- _loggedIn = false;
276
- _selectedWallet = null;
280
+ getCtx().loggedIn = false;
281
+ getCtx().selectedWallet = null;
277
282
  }
278
283
  // ── Auth ────────────────────────────────────────────────────────────────────
279
284
  export async function login(email, senha) {
280
285
  clearCookies();
281
- _loggedIn = false;
282
- _selectedWallet = null;
286
+ getCtx().loggedIn = false;
287
+ getCtx().selectedWallet = null;
283
288
  // 1. Pegar página de login para obter CSRF token e cookie
284
289
  const loginPage = await webGet("/Login");
285
290
  const csrf = extractCsrf(loginPage.text);
@@ -299,7 +304,7 @@ export async function login(email, senha) {
299
304
  ?? `Credenciais inválidas (status ${loginResp.status}, location: ${loginResp.location ?? "none"}).`;
300
305
  return { sucesso: false, mensagem: errMsg };
301
306
  }
302
- _loggedIn = true;
307
+ getCtx().loggedIn = true;
303
308
  saveSession();
304
309
  return { sucesso: true, mensagem: "Login realizado com sucesso." };
305
310
  }
@@ -311,9 +316,9 @@ export async function tryRestoreSession() {
311
316
  // Valida se a sessão web ainda funciona
312
317
  const resp = await webGet("/CarteiraAjax/Listar");
313
318
  if (resp.status === 200) {
314
- _loggedIn = true;
319
+ getCtx().loggedIn = true;
315
320
  // Re-selecionar carteira para renovar o token de carteira (c=) expirado em 5 min
316
- if (_selectedWallet) {
321
+ if (getCtx().selectedWallet) {
317
322
  await refreshCarteiraToken();
318
323
  }
319
324
  return true;
@@ -397,7 +402,7 @@ export async function selecionarCarteira(nomeCarteira) {
397
402
  const resp = await webPost("/Carteira", formBody, "application/x-www-form-urlencoded", false);
398
403
  const ok = resp.status === 302;
399
404
  if (ok) {
400
- _selectedWallet = idCarteira;
405
+ getCtx().selectedWallet = idCarteira;
401
406
  invalidarCacheContas();
402
407
  saveSession();
403
408
  }
@@ -471,13 +476,13 @@ function isContaAtiva(c) {
471
476
  }
472
477
  // Cache em memória da lista de contas (TTL 60s) para evitar carregar 1.2MB em
473
478
  // cada chamada de buscarLancamentos quando se itera por várias contas.
474
- let _contasCache = null;
475
479
  const CONTAS_CACHE_TTL_MS = 60 * 1000;
476
- export function invalidarCacheContas() { _contasCache = null; }
480
+ export function invalidarCacheContas() { getCtx().contasCache = null; }
477
481
  /** Tenta endpoints em ordem até encontrar um que responda com dados */
478
482
  async function fetchContas() {
479
- if (_contasCache && _contasCache.expira > Date.now()) {
480
- return { contas: _contasCache.contas, fonte: _contasCache.fonte };
483
+ const cache = getCtx().contasCache;
484
+ if (cache && cache.expira > Date.now()) {
485
+ return { contas: cache.contas, fonte: cache.fonte };
481
486
  }
482
487
  // Todos os endpoints REST agora apontam para api.finance.net.br via apiGet()
483
488
  const tentativas = [
@@ -502,7 +507,7 @@ async function fetchContas() {
502
507
  ? raw
503
508
  : (Object.values(raw).find(v => Array.isArray(v)) ?? []);
504
509
  if (arr.length > 0) {
505
- _contasCache = { contas: arr, fonte: path, expira: Date.now() + CONTAS_CACHE_TTL_MS };
510
+ getCtx().contasCache = { contas: arr, fonte: path, expira: Date.now() + CONTAS_CACHE_TTL_MS };
506
511
  return { contas: arr, fonte: path };
507
512
  }
508
513
  }
@@ -851,15 +856,22 @@ export async function buscarComprasProduto(opts = {}) {
851
856
  const total = items.length;
852
857
  const offset = (pagina - 1) * limite;
853
858
  const paginados = items.slice(offset, offset + limite);
854
- // Slim: apenas campos legíveis — sem IDs
859
+ // Slim: apenas campos legíveis — sem IDs.
860
+ // Nota: o endpoint /api/ProdutoBusiness/FiltrarCompra retorna o produto cadastrado
861
+ // (agregação por produto). Não traz a Despesa pai, então NÃO há como ratear desconto
862
+ // a nível de documento aqui. Use ultimo_preco_produto para obter o preço REAL pago
863
+ // (que cruza com Despesa/FiltrarDespesaServProd e aplica desconto proporcional).
855
864
  const compras = paginados.map(item => {
856
865
  const prod = item["ProdutoBusiness"];
857
866
  return {
858
- produto: String(prod?.["Nome"] ?? ""),
867
+ produto: String(prod?.["Descricao"] ?? prod?.["Nome"] ?? item["Codigo"] ?? ""),
859
868
  fornecedor: String(item["PessoaNome"] ?? ""),
860
869
  moeda: String(item["MoedaNome"] ?? "BRL"),
861
- valor: Number(item["Valor"] ?? 0),
870
+ valor: Number(item["Valor"] ?? 0), // preço bruto unitário (sem ratear desconto)
871
+ quantidade: Number(item["Quantidade"] ?? 0),
872
+ valor_total: Number(item["ValorTotal"] ?? 0),
862
873
  data: String(item["DataAtualizacao"] ?? "").slice(0, 10),
874
+ observacao: "Preço bruto sem desconto da nota. Para preço real pago, use 'ultimo_preco_produto'.",
863
875
  };
864
876
  });
865
877
  return {
@@ -1088,15 +1100,43 @@ async function resolverIdFornecedor(nome) {
1088
1100
  const primeiro = arr[0];
1089
1101
  return String(primeiro["Id"] ?? "");
1090
1102
  }
1091
- function slimItemDespesa(i) {
1103
+ /** Calcula valores REAIS (com desconto da nota distribuído) para um item de DespesaServProd.
1104
+ * Pode receber a Despesa pai inline (do FiltrarDespesaServProd) ou separadamente. */
1105
+ function aplicarDescontoProporcional(item, despesa) {
1106
+ const qtd = Number(item["Quantidade"] ?? 0);
1107
+ const valorBruto = Number(item["Valor"] ?? 0);
1108
+ const totalBruto = Number(item["ValorTotal"] ?? (qtd > 0 ? qtd * valorBruto : valorBruto));
1109
+ if (!despesa)
1110
+ return { precoUnitario: valorBruto, valorTotal: totalBruto };
1111
+ const despBruto = Number(despesa["Valor"] ?? 0);
1112
+ const despLiquido = Number(despesa["ValorLiquido"] ?? despBruto);
1113
+ if (despBruto <= 0 || Math.abs(despBruto - despLiquido) < 0.01) {
1114
+ return { precoUnitario: valorBruto, valorTotal: totalBruto };
1115
+ }
1116
+ const fator = despLiquido / despBruto;
1117
+ const totalReal = Math.round(totalBruto * fator * 100) / 100;
1118
+ const precoReal = qtd > 0 ? Math.round((totalReal / qtd) * 100) / 100 : valorBruto * fator;
1119
+ return {
1120
+ precoUnitario: precoReal,
1121
+ valorTotal: totalReal,
1122
+ descontoAplicado: Math.round((totalBruto - totalReal) * 100) / 100,
1123
+ precoUnitarioBruto: valorBruto,
1124
+ valorTotalBruto: totalBruto,
1125
+ };
1126
+ }
1127
+ function slimItemDespesa(i, despesa) {
1128
+ const calc = aplicarDescontoProporcional(i, despesa);
1092
1129
  return {
1093
1130
  descricao: String(i["DescricaoServProd"] ?? "").trim(),
1094
1131
  codigo: i["Codigo"] ? String(i["Codigo"]).trim() : undefined,
1095
1132
  quantidade: Number(i["Quantidade"] ?? 0),
1096
1133
  unidade: i["UnidadeMedida"] ? String(i["UnidadeMedida"]).trim() : undefined,
1097
- valor: Number(i["Valor"] ?? 0),
1134
+ valor: calc.precoUnitario,
1098
1135
  desconto: Number(i["Desconto"] ?? 0),
1099
- valor_total: Number(i["ValorTotal"] ?? 0),
1136
+ valor_total: calc.valorTotal,
1137
+ valor_bruto: calc.precoUnitarioBruto,
1138
+ valor_total_bruto: calc.valorTotalBruto,
1139
+ desconto_aplicado: calc.descontoAplicado,
1100
1140
  };
1101
1141
  }
1102
1142
  function slimDespesa(d, comItens) {
@@ -1110,7 +1150,8 @@ function slimDespesa(d, comItens) {
1110
1150
  valor_liquido: Number(d["ValorLiquido"] ?? 0),
1111
1151
  };
1112
1152
  if (comItens && itensRaw.length > 0) {
1113
- result.itens = itensRaw.map(slimItemDespesa);
1153
+ // Passa a despesa pai (d) pra distribuir descontos proporcionalmente
1154
+ result.itens = itensRaw.map(i => slimItemDespesa(i, d));
1114
1155
  }
1115
1156
  return result;
1116
1157
  }
@@ -1179,19 +1220,23 @@ export async function buscarItensDespesa(opts = {}) {
1179
1220
  const total = itens.length;
1180
1221
  const inicio = (pagina - 1) * limite;
1181
1222
  const resultado = itens.slice(inicio, inicio + limite).map(i => {
1182
- const despesa = i["Despesa"] ?? {};
1223
+ const despesa = i["Despesa"] ?? null;
1224
+ const calc = aplicarDescontoProporcional(i, despesa);
1183
1225
  return {
1184
1226
  descricao: String(i["DescricaoServProd"] ?? "").trim(),
1185
1227
  codigo: i["Codigo"] ? String(i["Codigo"]).trim() : undefined,
1186
1228
  quantidade: Number(i["Quantidade"] ?? 0),
1187
1229
  unidade: i["UnidadeMedida"] ? String(i["UnidadeMedida"]).trim() : undefined,
1188
- valor: Number(i["Valor"] ?? 0),
1230
+ valor: calc.precoUnitario, // REAL pago
1189
1231
  desconto: Number(i["Desconto"] ?? 0),
1190
- valor_total: Number(i["ValorTotal"] ?? 0),
1232
+ valor_total: calc.valorTotal, // REAL pago
1233
+ valor_bruto: calc.precoUnitarioBruto,
1234
+ valor_total_bruto: calc.valorTotalBruto,
1235
+ desconto_aplicado: calc.descontoAplicado,
1191
1236
  // Dados do documento pai — sem IDs
1192
- data: String(despesa["Data"] ?? "").slice(0, 10),
1193
- numero: String(despesa["Numero"] ?? "").trim(),
1194
- fornecedor: String(despesa["PessoaNome"] ?? "").trim(),
1237
+ data: String(despesa?.["Data"] ?? "").slice(0, 10),
1238
+ numero: String(despesa?.["Numero"] ?? "").trim(),
1239
+ fornecedor: String(despesa?.["PessoaNome"] ?? "").trim(),
1195
1240
  };
1196
1241
  });
1197
1242
  return {
@@ -1541,36 +1586,16 @@ export async function ultimoPrecoProduto(opts) {
1541
1586
  const dataIso = String(it["DataAtualizacao"] ?? "");
1542
1587
  const despesa = it["Despesa"] ?? null;
1543
1588
  const data = dataIso || String(despesa?.["Data"] ?? "");
1544
- const qtd = Number(it["Quantidade"] ?? 0);
1545
- const valorBruto = Number(it["Valor"] ?? 0);
1546
- const totalBruto = Number(it["ValorTotal"] ?? (qtd > 0 ? qtd * valorBruto : valorBruto));
1547
- // Distribui descontos a nível de despesa proporcionalmente ao valor do item.
1548
- // Despesa.Valor = total bruto; Despesa.ValorLiquido = líquido pago.
1549
- // Fator = ValorLiquido / Valor (entre 0 e 1).
1550
- let precoUnitarioReal = valorBruto;
1551
- let totalReal = totalBruto;
1552
- let descontoAplicado;
1553
- let precoBrutoOut;
1554
- if (despesa) {
1555
- const despBruto = Number(despesa["Valor"] ?? 0);
1556
- const despLiquido = Number(despesa["ValorLiquido"] ?? despBruto);
1557
- if (despBruto > 0 && Math.abs(despBruto - despLiquido) > 0.01) {
1558
- const fator = despLiquido / despBruto;
1559
- totalReal = Math.round(totalBruto * fator * 100) / 100;
1560
- precoUnitarioReal = qtd > 0 ? Math.round((totalReal / qtd) * 100) / 100 : valorBruto * fator;
1561
- descontoAplicado = Math.round((totalBruto - totalReal) * 100) / 100;
1562
- precoBrutoOut = valorBruto;
1563
- }
1564
- }
1589
+ const calc = aplicarDescontoProporcional(it, despesa);
1565
1590
  return {
1566
1591
  data: data.slice(0, 10),
1567
1592
  produto: nomeProd.trim(),
1568
1593
  fornecedor: String(it["PessoaNome"] ?? despesa?.["PessoaNome"] ?? "").trim(),
1569
- preco_unitario: precoUnitarioReal,
1570
- quantidade: qtd,
1571
- valor_total: totalReal,
1572
- preco_unitario_bruto: precoBrutoOut,
1573
- desconto_aplicado: descontoAplicado,
1594
+ preco_unitario: calc.precoUnitario,
1595
+ quantidade: Number(it["Quantidade"] ?? 0),
1596
+ valor_total: calc.valorTotal,
1597
+ preco_unitario_bruto: calc.precoUnitarioBruto,
1598
+ desconto_aplicado: calc.descontoAplicado,
1574
1599
  moeda: it["MoedaNome"] ? String(it["MoedaNome"]) : undefined,
1575
1600
  };
1576
1601
  })
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Per-context state holder usando AsyncLocalStorage.
3
+ *
4
+ * Objetivo: permitir multi-tenancy quando o MCP rodar como serviço HTTPS
5
+ * (cada requisição com sua própria sessão NEXT), mantendo o modo stdio
6
+ * (uma única sessão global lida de ~/.next-finance-mcp/session.json)
7
+ * funcionando exatamente igual ao atual.
8
+ *
9
+ * USO:
10
+ * - stdio: `enterDefaultCtx()` é chamado UMA vez no bootstrap; daí em
11
+ * diante todas as chamadas usam o contexto default.
12
+ * - http : `runInCtx(ctx, async () => { ... })` envolve cada handler
13
+ * da request com seu próprio contexto isolado.
14
+ */
15
+ export interface Cookie {
16
+ name: string;
17
+ value: string;
18
+ domain: string;
19
+ }
20
+ export interface ClientContext {
21
+ /** Cookies acumulados para o domínio finance.net.br (inclui cookieAutenticacao). */
22
+ cookies: Cookie[];
23
+ /** Estado de auth: true após login (web) bem-sucedido ou tryRestoreSession. */
24
+ loggedIn: boolean;
25
+ /** IdCarteira selecionada (ex: "Pa"). Usado para auto-refresh do token c=. */
26
+ selectedWallet: string | null;
27
+ /** Cache de fetchContas com TTL — invalidado em selecionar_carteira. */
28
+ contasCache: {
29
+ contas: unknown[];
30
+ fonte: string;
31
+ expira: number;
32
+ } | null;
33
+ /** Identificador opcional do tenant (para logs/telemetria no modo HTTP). */
34
+ tenantId?: string;
35
+ }
36
+ export declare function createCtx(init?: Partial<ClientContext>): ClientContext;
37
+ /** Lê o contexto da chamada atual. Falha se ninguém entrou em contexto antes. */
38
+ export declare function getCtx(): ClientContext;
39
+ /** Retorna o contexto atual sem lançar — útil para checagens defensivas. */
40
+ export declare function peekCtx(): ClientContext | undefined;
41
+ /** Executa `fn` com `ctx` ligado ao contexto async. Usado por handlers HTTP. */
42
+ export declare function runInCtx<T>(ctx: ClientContext, fn: () => Promise<T>): Promise<T>;
43
+ /** Modo stdio: define o contexto default UMA vez no startup. */
44
+ export declare function enterDefaultCtx(ctx?: ClientContext): void;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Per-context state holder usando AsyncLocalStorage.
3
+ *
4
+ * Objetivo: permitir multi-tenancy quando o MCP rodar como serviço HTTPS
5
+ * (cada requisição com sua própria sessão NEXT), mantendo o modo stdio
6
+ * (uma única sessão global lida de ~/.next-finance-mcp/session.json)
7
+ * funcionando exatamente igual ao atual.
8
+ *
9
+ * USO:
10
+ * - stdio: `enterDefaultCtx()` é chamado UMA vez no bootstrap; daí em
11
+ * diante todas as chamadas usam o contexto default.
12
+ * - http : `runInCtx(ctx, async () => { ... })` envolve cada handler
13
+ * da request com seu próprio contexto isolado.
14
+ */
15
+ import { AsyncLocalStorage } from "async_hooks";
16
+ export function createCtx(init) {
17
+ return {
18
+ cookies: init?.cookies ?? [],
19
+ loggedIn: init?.loggedIn ?? false,
20
+ selectedWallet: init?.selectedWallet ?? null,
21
+ contasCache: init?.contasCache ?? null,
22
+ tenantId: init?.tenantId,
23
+ };
24
+ }
25
+ const als = new AsyncLocalStorage();
26
+ /** Lê o contexto da chamada atual. Falha se ninguém entrou em contexto antes. */
27
+ export function getCtx() {
28
+ const ctx = als.getStore();
29
+ if (!ctx) {
30
+ throw new Error("ClientContext não inicializado. Chame enterDefaultCtx() (stdio) ou " +
31
+ "runInCtx(ctx, fn) (http) antes de invocar funções do client.");
32
+ }
33
+ return ctx;
34
+ }
35
+ /** Retorna o contexto atual sem lançar — útil para checagens defensivas. */
36
+ export function peekCtx() {
37
+ return als.getStore();
38
+ }
39
+ /** Executa `fn` com `ctx` ligado ao contexto async. Usado por handlers HTTP. */
40
+ export function runInCtx(ctx, fn) {
41
+ return als.run(ctx, fn);
42
+ }
43
+ /** Modo stdio: define o contexto default UMA vez no startup. */
44
+ export function enterDefaultCtx(ctx = createCtx()) {
45
+ als.enterWith(ctx);
46
+ }
@@ -10,4 +10,9 @@
10
10
  * Opcional:
11
11
  * PORT — porta do servidor (padrão: 3000)
12
12
  */
13
- export {};
13
+ import { type ClientContext } from "./context.js";
14
+ /** Helper pronto para o futuro fluxo multi-tenant.
15
+ * Recebe Authorization: Basic <jwt> e cria um ClientContext isolado.
16
+ * Use runInCtx(buildCtxFromAuthHeader(req.headers.authorization), () => ...). */
17
+ export declare function buildCtxFromAuthHeader(authorization: string | undefined, tenantId?: string): ClientContext;
18
+ export declare function runMcpHandler<T>(authorization: string | undefined, fn: () => Promise<T>): Promise<T>;
@@ -16,6 +16,28 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
16
16
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
17
17
  import { randomUUID } from "crypto";
18
18
  import * as client from "./client.js";
19
+ import { enterDefaultCtx, createCtx, runInCtx } from "./context.js";
20
+ // Modo HTTP single-tenant atual (env NEXT_FINANCE_EMAIL/PASSWORD) — ativa contexto único.
21
+ // TODO multi-tenant: substituir essa linha por wrap runInCtx() por requisição.
22
+ enterDefaultCtx();
23
+ /** Helper pronto para o futuro fluxo multi-tenant.
24
+ * Recebe Authorization: Basic <jwt> e cria um ClientContext isolado.
25
+ * Use runInCtx(buildCtxFromAuthHeader(req.headers.authorization), () => ...). */
26
+ export function buildCtxFromAuthHeader(authorization, tenantId) {
27
+ const ctx = createCtx({ tenantId });
28
+ if (authorization?.startsWith("Basic ")) {
29
+ const jwt = authorization.slice("Basic ".length);
30
+ // O JWT enviado vai pra Authorization Basic em apiGet/apiPost via getAuthHeader().
31
+ // Aqui só guardamos no contexto para que getCookieHeader retorne string compatível
32
+ // (mesmo que algumas chamadas web ainda usem cookies).
33
+ ctx.cookies.push({ name: "cookieAutenticacao", value: `tuf=${jwt}`, domain: "finance.net.br" });
34
+ ctx.loggedIn = true;
35
+ }
36
+ return ctx;
37
+ }
38
+ export async function runMcpHandler(authorization, fn) {
39
+ return runInCtx(buildCtxFromAuthHeader(authorization), fn);
40
+ }
19
41
  // ── Configuração ─────────────────────────────────────────────────────────────
20
42
  const PORT = parseInt(process.env.PORT ?? "3000", 10);
21
43
  const API_KEY = process.env.API_KEY ?? "";
package/dist/index.js CHANGED
@@ -7,8 +7,11 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
8
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
9
9
  import * as client from "./client.js";
10
+ import { enterDefaultCtx } from "./context.js";
10
11
  import { openLoginUI } from "./login-server.js";
11
- const server = new Server({ name: "next-finance-mcp", version: "0.9.21" }, { capabilities: { tools: {} } });
12
+ // Modo stdio: ativa um contexto único global no startup. Multi-tenancy no modo HTTP.
13
+ enterDefaultCtx();
14
+ const server = new Server({ name: "next-finance-mcp", version: "0.9.23" }, { capabilities: { tools: {} } });
12
15
  // ── Ferramentas ─────────────────────────────────────────────────────────────
13
16
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
14
17
  tools: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-finance-mcp",
3
- "version": "0.9.21",
3
+ "version": "0.9.23",
4
4
  "mcpName": "io.github.paivapiovesan/next-finance",
5
5
  "description": "MCP Server para o NEXT Finance (finance.net.br) — login via browser, listagem de carteiras, contas e lançamentos",
6
6
  "type": "module",