next-finance-mcp 0.3.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 +59 -0
- package/dist/client.d.ts +21 -0
- package/dist/client.js +211 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +149 -0
- package/dist/login-server.d.ts +11 -0
- package/dist/login-server.js +205 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# next-finance-mcp
|
|
2
|
+
|
|
3
|
+
MCP Server para o **[NEXT Finance](https://finance.net.br)** — conecta o Claude ao seu ERP financeiro com login seguro via browser.
|
|
4
|
+
|
|
5
|
+
## Funcionalidades
|
|
6
|
+
|
|
7
|
+
- 🔐 **Login via browser** — credenciais nunca passam pelo chat
|
|
8
|
+
- 💾 **Sessão persistente** — não precisa logar toda vez (válida por 3 dias)
|
|
9
|
+
- 💼 **Listar carteiras** — veja todas as suas carteiras disponíveis
|
|
10
|
+
- 🏦 **Listar contas** — contas correntes, investimentos, cartões, etc.
|
|
11
|
+
- 📊 **Buscar lançamentos** — transações com filtro por data
|
|
12
|
+
|
|
13
|
+
## Instalação no Claude Desktop
|
|
14
|
+
|
|
15
|
+
Adicione ao seu `claude_desktop_config.json`:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"mcpServers": {
|
|
20
|
+
"next-finance": {
|
|
21
|
+
"command": "npx",
|
|
22
|
+
"args": ["-y", "next-finance-mcp"]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Localização do arquivo:**
|
|
29
|
+
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
30
|
+
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
31
|
+
|
|
32
|
+
Reinicie o Claude Desktop após salvar.
|
|
33
|
+
|
|
34
|
+
## Como usar
|
|
35
|
+
|
|
36
|
+
1. Peça ao Claude para fazer login: *"Faça login no NEXT Finance"*
|
|
37
|
+
2. O browser abrirá automaticamente com o formulário de login
|
|
38
|
+
3. Preencha e-mail e senha — as credenciais ficam apenas no browser
|
|
39
|
+
4. De volta ao Claude, liste as carteiras e comece a usar
|
|
40
|
+
|
|
41
|
+
## Ferramentas disponíveis
|
|
42
|
+
|
|
43
|
+
| Ferramenta | Descrição |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `login` | Abre o browser para login seguro (ou restaura sessão salva) |
|
|
46
|
+
| `logout` | Encerra a sessão |
|
|
47
|
+
| `listar_carteiras` | Lista todas as carteiras do usuário |
|
|
48
|
+
| `selecionar_carteira` | Seleciona uma carteira pelo ID |
|
|
49
|
+
| `listar_contas` | Lista as contas da carteira selecionada |
|
|
50
|
+
| `buscar_lancamentos` | Busca lançamentos de uma conta por período |
|
|
51
|
+
|
|
52
|
+
## Requisitos
|
|
53
|
+
|
|
54
|
+
- Node.js 18+
|
|
55
|
+
- Conta no [NEXT Finance](https://finance.net.br)
|
|
56
|
+
|
|
57
|
+
## Licença
|
|
58
|
+
|
|
59
|
+
MIT
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cliente HTTP para o NEXT Finance (finance.net.br).
|
|
3
|
+
* Usa autenticação via cookie de sessão (form POST login).
|
|
4
|
+
*/
|
|
5
|
+
export declare function saveSession(): void;
|
|
6
|
+
export declare function loadSession(): boolean;
|
|
7
|
+
export declare function deleteSession(): void;
|
|
8
|
+
export declare function isAuthenticated(): boolean;
|
|
9
|
+
export declare function getSelectedWallet(): string | null;
|
|
10
|
+
export declare function logout(): void;
|
|
11
|
+
export declare function login(email: string, senha: string): Promise<Record<string, unknown>>;
|
|
12
|
+
/** Tenta restaurar sessão salva. Retorna true se a sessão parece válida. */
|
|
13
|
+
export declare function tryRestoreSession(): Promise<boolean>;
|
|
14
|
+
export declare function listarCarteiras(): Promise<unknown>;
|
|
15
|
+
export declare function selecionarCarteira(idCarteira: string): Promise<Record<string, unknown>>;
|
|
16
|
+
export declare function listarContas(): Promise<unknown>;
|
|
17
|
+
export declare function buscarLancamentos(opts: {
|
|
18
|
+
idConta: string;
|
|
19
|
+
dataInicio?: string;
|
|
20
|
+
dataFim?: string;
|
|
21
|
+
}): Promise<unknown>;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cliente HTTP para o NEXT Finance (finance.net.br).
|
|
3
|
+
* Usa autenticação via cookie de sessão (form POST login).
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import * as os from "os";
|
|
8
|
+
const WEB_BASE = "https://finance.net.br";
|
|
9
|
+
const SESSION_DIR = path.join(os.homedir(), ".next-finance-mcp");
|
|
10
|
+
const SESSION_FILE = path.join(SESSION_DIR, "session.json");
|
|
11
|
+
export function saveSession() {
|
|
12
|
+
try {
|
|
13
|
+
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
|
14
|
+
const data = { cookies: [...cookieStore], savedAt: new Date().toISOString() };
|
|
15
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
16
|
+
}
|
|
17
|
+
catch { /* ignora erros de I/O */ }
|
|
18
|
+
}
|
|
19
|
+
export function loadSession() {
|
|
20
|
+
try {
|
|
21
|
+
const raw = fs.readFileSync(SESSION_FILE, "utf-8");
|
|
22
|
+
const data = JSON.parse(raw);
|
|
23
|
+
// Aceita sessões salvas há menos de 3 dias
|
|
24
|
+
const age = Date.now() - new Date(data.savedAt).getTime();
|
|
25
|
+
if (age > 3 * 24 * 60 * 60 * 1000)
|
|
26
|
+
return false;
|
|
27
|
+
cookieStore.length = 0;
|
|
28
|
+
cookieStore.push(...data.cookies);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function deleteSession() {
|
|
36
|
+
try {
|
|
37
|
+
fs.unlinkSync(SESSION_FILE);
|
|
38
|
+
}
|
|
39
|
+
catch { /* ignora */ }
|
|
40
|
+
}
|
|
41
|
+
const cookieStore = [];
|
|
42
|
+
function storeCookies(setCookieHeaders, domain) {
|
|
43
|
+
for (const header of setCookieHeaders) {
|
|
44
|
+
const pair = header.split(";")[0].trim();
|
|
45
|
+
const eqIdx = pair.indexOf("=");
|
|
46
|
+
if (eqIdx < 0)
|
|
47
|
+
continue;
|
|
48
|
+
const name = pair.slice(0, eqIdx).trim();
|
|
49
|
+
const value = pair.slice(eqIdx + 1).trim();
|
|
50
|
+
const existing = cookieStore.findIndex(c => c.name === name && c.domain === domain);
|
|
51
|
+
if (existing >= 0)
|
|
52
|
+
cookieStore[existing].value = value;
|
|
53
|
+
else
|
|
54
|
+
cookieStore.push({ name, value, domain });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function getCookieHeader(domain) {
|
|
58
|
+
return cookieStore
|
|
59
|
+
.filter(c => domain.includes(c.domain) || c.domain.includes(domain))
|
|
60
|
+
.map(c => `${c.name}=${c.value}`)
|
|
61
|
+
.join("; ");
|
|
62
|
+
}
|
|
63
|
+
function clearCookies() {
|
|
64
|
+
cookieStore.length = 0;
|
|
65
|
+
}
|
|
66
|
+
// ── Helpers de request ─────────────────────────────────────────────────────
|
|
67
|
+
const BASE_HEADERS = {
|
|
68
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
69
|
+
"Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
|
|
70
|
+
};
|
|
71
|
+
async function webGet(path) {
|
|
72
|
+
const url = `${WEB_BASE}${path}`;
|
|
73
|
+
const res = await fetch(url, {
|
|
74
|
+
headers: {
|
|
75
|
+
...BASE_HEADERS,
|
|
76
|
+
Cookie: getCookieHeader("finance.net.br"),
|
|
77
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,application/json,*/*;q=0.8",
|
|
78
|
+
},
|
|
79
|
+
redirect: "follow",
|
|
80
|
+
});
|
|
81
|
+
const setCookies = res.headers.getSetCookie?.() ?? [];
|
|
82
|
+
storeCookies(setCookies, "finance.net.br");
|
|
83
|
+
return { status: res.status, text: await res.text(), headers: res.headers };
|
|
84
|
+
}
|
|
85
|
+
async function webPost(path, body, contentType, followRedirect = true) {
|
|
86
|
+
const url = `${WEB_BASE}${path}`;
|
|
87
|
+
const res = await fetch(url, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: {
|
|
90
|
+
...BASE_HEADERS,
|
|
91
|
+
Cookie: getCookieHeader("finance.net.br"),
|
|
92
|
+
"Content-Type": contentType,
|
|
93
|
+
Accept: "application/json, text/html, */*",
|
|
94
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
95
|
+
},
|
|
96
|
+
body,
|
|
97
|
+
redirect: followRedirect ? "follow" : "manual",
|
|
98
|
+
});
|
|
99
|
+
const setCookies = res.headers.getSetCookie?.() ?? [];
|
|
100
|
+
storeCookies(setCookies, "finance.net.br");
|
|
101
|
+
return { status: res.status, text: await res.text(), location: res.headers.get("location") ?? undefined };
|
|
102
|
+
}
|
|
103
|
+
function extractCsrf(html) {
|
|
104
|
+
const m = html.match(/name="__RequestVerificationToken"[^>]+value="([^"]+)"/);
|
|
105
|
+
return m?.[1] ?? "";
|
|
106
|
+
}
|
|
107
|
+
// ── Estado da sessão ────────────────────────────────────────────────────────
|
|
108
|
+
let _loggedIn = false;
|
|
109
|
+
let _selectedWallet = null;
|
|
110
|
+
export function isAuthenticated() { return _loggedIn; }
|
|
111
|
+
export function getSelectedWallet() { return _selectedWallet; }
|
|
112
|
+
export function logout() {
|
|
113
|
+
clearCookies();
|
|
114
|
+
_loggedIn = false;
|
|
115
|
+
_selectedWallet = null;
|
|
116
|
+
}
|
|
117
|
+
// ── Auth ────────────────────────────────────────────────────────────────────
|
|
118
|
+
export async function login(email, senha) {
|
|
119
|
+
clearCookies();
|
|
120
|
+
_loggedIn = false;
|
|
121
|
+
_selectedWallet = null;
|
|
122
|
+
// 1. Pegar página de login para obter CSRF token e cookie
|
|
123
|
+
const loginPage = await webGet("/Login");
|
|
124
|
+
const csrf = extractCsrf(loginPage.text);
|
|
125
|
+
if (!csrf)
|
|
126
|
+
return { sucesso: false, mensagem: "Não foi possível obter o token CSRF da página de login." };
|
|
127
|
+
// 2. Submeter o formulário de login sem seguir redirect (para capturar cookies da resposta 302)
|
|
128
|
+
const formBody = new URLSearchParams({ Email: email, Senha: senha, __RequestVerificationToken: csrf }).toString();
|
|
129
|
+
const loginResp = await webPost("/Login", formBody, "application/x-www-form-urlencoded", false);
|
|
130
|
+
// Login bem-sucedido retorna 302 redirect para /Carteira
|
|
131
|
+
const isSuccess = loginResp.status === 302 && (loginResp.location?.includes("/Carteira") ||
|
|
132
|
+
loginResp.location?.includes("/carteira") ||
|
|
133
|
+
loginResp.location === "/");
|
|
134
|
+
if (!isSuccess) {
|
|
135
|
+
// Na resposta 302, o corpo pode estar vazio; tentar extrair mensagem de erro se houver
|
|
136
|
+
const errMsg = loginResp.text.match(/mensagemErroVal\"[^>]+value=\"([^\"]+)\"/)?.[1]?.trim()
|
|
137
|
+
?? loginResp.text.match(/texto-mensagem-erro[^>]*>([^<]+)/)?.[1]?.trim()
|
|
138
|
+
?? `Credenciais inválidas (status ${loginResp.status}, location: ${loginResp.location ?? "none"}).`;
|
|
139
|
+
return { sucesso: false, mensagem: errMsg };
|
|
140
|
+
}
|
|
141
|
+
_loggedIn = true;
|
|
142
|
+
saveSession();
|
|
143
|
+
return { sucesso: true, mensagem: "Login realizado com sucesso." };
|
|
144
|
+
}
|
|
145
|
+
/** Tenta restaurar sessão salva. Retorna true se a sessão parece válida. */
|
|
146
|
+
export async function tryRestoreSession() {
|
|
147
|
+
if (!loadSession())
|
|
148
|
+
return false;
|
|
149
|
+
try {
|
|
150
|
+
// Valida se a sessão ainda funciona
|
|
151
|
+
const resp = await webGet("/CarteiraAjax/Listar");
|
|
152
|
+
if (resp.status === 200) {
|
|
153
|
+
_loggedIn = true;
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch { /* ignora */ }
|
|
158
|
+
clearCookies();
|
|
159
|
+
deleteSession();
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
// ── Carteiras ───────────────────────────────────────────────────────────────
|
|
163
|
+
export async function listarCarteiras() {
|
|
164
|
+
const resp = await webGet("/CarteiraAjax/Listar");
|
|
165
|
+
if (resp.status !== 200)
|
|
166
|
+
throw new Error(`Erro ${resp.status} ao listar carteiras.`);
|
|
167
|
+
return JSON.parse(resp.text);
|
|
168
|
+
}
|
|
169
|
+
export async function selecionarCarteira(idCarteira) {
|
|
170
|
+
// Precisamos do CSRF token da página de carteiras
|
|
171
|
+
const cartPage = await webGet("/Carteira");
|
|
172
|
+
const csrf = extractCsrf(cartPage.text);
|
|
173
|
+
const formBody = new URLSearchParams({ __RequestVerificationToken: csrf, idCarteira }).toString();
|
|
174
|
+
// Não seguir redirect: o servidor retorna 302 → /Inicio ao selecionar com sucesso.
|
|
175
|
+
// Seguir automaticamente faz o Node.js perder os Set-Cookie do 302.
|
|
176
|
+
const resp = await webPost("/Carteira", formBody, "application/x-www-form-urlencoded", false);
|
|
177
|
+
const ok = resp.status === 302;
|
|
178
|
+
if (ok) {
|
|
179
|
+
_selectedWallet = idCarteira;
|
|
180
|
+
saveSession();
|
|
181
|
+
}
|
|
182
|
+
return { sucesso: ok, mensagem: ok ? `Carteira ${idCarteira} selecionada.` : `Falha ao selecionar carteira (${resp.status}).` };
|
|
183
|
+
}
|
|
184
|
+
// ── Contas ──────────────────────────────────────────────────────────────────
|
|
185
|
+
export async function listarContas() {
|
|
186
|
+
const resp = await webGet("/ContaAjax/ListarTodos");
|
|
187
|
+
if (resp.status !== 200)
|
|
188
|
+
throw new Error(`Erro ${resp.status} ao listar contas.`);
|
|
189
|
+
return JSON.parse(resp.text);
|
|
190
|
+
}
|
|
191
|
+
// ── Lançamentos ─────────────────────────────────────────────────────────────
|
|
192
|
+
//
|
|
193
|
+
// O endpoint usa jQuery $.ajax padrão → form-urlencoded com chaves aninhadas:
|
|
194
|
+
// filtro[IdConta]=X&filtro[IntervaloDeDatas][DataInicio]=Y&filtro[IntervaloDeDatas][DataFim]=Z
|
|
195
|
+
//
|
|
196
|
+
// IdConta é OBRIGATÓRIO (cada chamada é por conta).
|
|
197
|
+
export async function buscarLancamentos(opts) {
|
|
198
|
+
// DataInicio e DataFim são obrigatórios pela API; usar mês atual como default
|
|
199
|
+
const today = new Date();
|
|
200
|
+
const defaultInicio = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-01`;
|
|
201
|
+
const defaultFim = today.toISOString().slice(0, 10);
|
|
202
|
+
const params = new URLSearchParams();
|
|
203
|
+
params.set("filtro[IdConta]", opts.idConta);
|
|
204
|
+
params.set("filtro[IntervaloDeDatas][DataInicio]", opts.dataInicio ?? defaultInicio);
|
|
205
|
+
params.set("filtro[IntervaloDeDatas][DataFim]", opts.dataFim ?? defaultFim);
|
|
206
|
+
params.set("filtro[ListarOrdenadoPelaDataDeModificacao]", "false");
|
|
207
|
+
const resp = await webPost("/LancamentoAjax/Filtrar", params.toString(), "application/x-www-form-urlencoded");
|
|
208
|
+
if (resp.status !== 200)
|
|
209
|
+
throw new Error(`Erro ${resp.status}: ${resp.text.slice(0, 200)}`);
|
|
210
|
+
return JSON.parse(resp.text);
|
|
211
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Servidor MCP para o NEXT Finance (finance.net.br).
|
|
4
|
+
* Login via browser (sem credenciais no chat) + persistência de sessão.
|
|
5
|
+
*/
|
|
6
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import * as client from "./client.js";
|
|
10
|
+
import { openLoginUI } from "./login-server.js";
|
|
11
|
+
const server = new Server({ name: "next-finance-mcp", version: "0.3.0" }, { capabilities: { tools: {} } });
|
|
12
|
+
// ── Ferramentas ─────────────────────────────────────────────────────────────
|
|
13
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
14
|
+
tools: [
|
|
15
|
+
{
|
|
16
|
+
name: "login",
|
|
17
|
+
description: "Abre uma janela no browser para login seguro no NEXT Finance. " +
|
|
18
|
+
"As credenciais não passam pelo chat. Se já houver sessão salva, " +
|
|
19
|
+
"restaura automaticamente sem abrir o browser.",
|
|
20
|
+
inputSchema: { type: "object", properties: {} },
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "logout",
|
|
24
|
+
description: "Encerra a sessão e remove os dados de sessão salvos.",
|
|
25
|
+
inputSchema: { type: "object", properties: {} },
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "listar_carteiras",
|
|
29
|
+
description: "Lista todas as carteiras disponíveis para o usuário logado.",
|
|
30
|
+
inputSchema: { type: "object", properties: {} },
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "selecionar_carteira",
|
|
34
|
+
description: "Seleciona uma carteira pelo ID (necessário antes de listar contas e lançamentos). " +
|
|
35
|
+
"Use listar_carteiras para ver os IDs disponíveis.",
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
id_carteira: { type: "string", description: "ID da carteira (ex: 'Pa', 'AqR')" },
|
|
40
|
+
},
|
|
41
|
+
required: ["id_carteira"],
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "listar_contas",
|
|
46
|
+
description: "Lista as contas da carteira selecionada.",
|
|
47
|
+
inputSchema: { type: "object", properties: {} },
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "buscar_lancamentos",
|
|
51
|
+
description: "Busca lançamentos/transações de uma conta. " +
|
|
52
|
+
"Use listar_contas para obter os IDs de conta disponíveis. " +
|
|
53
|
+
"Se não informar datas, retorna o mês atual.",
|
|
54
|
+
inputSchema: {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: {
|
|
57
|
+
id_conta: { type: "string", description: "ID da conta (use listar_contas para descobrir)" },
|
|
58
|
+
data_inicio: { type: "string", description: "Data início YYYY-MM-DD (padrão: 1º do mês atual)" },
|
|
59
|
+
data_fim: { type: "string", description: "Data fim YYYY-MM-DD (padrão: hoje)" },
|
|
60
|
+
},
|
|
61
|
+
required: ["id_conta"],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
}));
|
|
66
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
67
|
+
function ok(data) {
|
|
68
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
69
|
+
}
|
|
70
|
+
function requireAuth() {
|
|
71
|
+
if (!client.isAuthenticated())
|
|
72
|
+
return ok({ erro: "Não autenticado. Use a ferramenta `login` primeiro." });
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
function requireWallet() {
|
|
76
|
+
if (!client.getSelectedWallet())
|
|
77
|
+
return ok({ erro: "Nenhuma carteira selecionada. Use `selecionar_carteira` após o login." });
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
// ── Execução ────────────────────────────────────────────────────────────────
|
|
81
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
82
|
+
const { name, arguments: args = {} } = req.params;
|
|
83
|
+
try {
|
|
84
|
+
switch (name) {
|
|
85
|
+
// ── Login: tenta restaurar sessão salva; senão abre o browser ──────────
|
|
86
|
+
case "login": {
|
|
87
|
+
// 1. Tentar sessão salva
|
|
88
|
+
const restored = await client.tryRestoreSession();
|
|
89
|
+
if (restored) {
|
|
90
|
+
return ok({ sucesso: true, mensagem: "Sessão restaurada automaticamente. Pronto para usar!" });
|
|
91
|
+
}
|
|
92
|
+
// 2. Abrir browser para login
|
|
93
|
+
const result = await openLoginUI(client.login);
|
|
94
|
+
return ok(result);
|
|
95
|
+
}
|
|
96
|
+
case "logout": {
|
|
97
|
+
client.logout();
|
|
98
|
+
client.deleteSession();
|
|
99
|
+
return ok({ mensagem: "Sessão encerrada e dados removidos." });
|
|
100
|
+
}
|
|
101
|
+
case "listar_carteiras": {
|
|
102
|
+
const authErr = requireAuth();
|
|
103
|
+
if (authErr)
|
|
104
|
+
return authErr;
|
|
105
|
+
return ok(await client.listarCarteiras());
|
|
106
|
+
}
|
|
107
|
+
case "selecionar_carteira": {
|
|
108
|
+
const authErr = requireAuth();
|
|
109
|
+
if (authErr)
|
|
110
|
+
return authErr;
|
|
111
|
+
return ok(await client.selecionarCarteira(args["id_carteira"]));
|
|
112
|
+
}
|
|
113
|
+
case "listar_contas": {
|
|
114
|
+
const authErr = requireAuth();
|
|
115
|
+
if (authErr)
|
|
116
|
+
return authErr;
|
|
117
|
+
const walErr = requireWallet();
|
|
118
|
+
if (walErr)
|
|
119
|
+
return walErr;
|
|
120
|
+
return ok(await client.listarContas());
|
|
121
|
+
}
|
|
122
|
+
case "buscar_lancamentos": {
|
|
123
|
+
const authErr = requireAuth();
|
|
124
|
+
if (authErr)
|
|
125
|
+
return authErr;
|
|
126
|
+
const walErr = requireWallet();
|
|
127
|
+
if (walErr)
|
|
128
|
+
return walErr;
|
|
129
|
+
return ok(await client.buscarLancamentos({
|
|
130
|
+
idConta: args["id_conta"],
|
|
131
|
+
dataInicio: args["data_inicio"],
|
|
132
|
+
dataFim: args["data_fim"],
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
default:
|
|
136
|
+
return ok({ erro: `Ferramenta desconhecida: ${name}` });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
return ok({ erro: err instanceof Error ? err.message : String(err) });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
// ── Inicialização ───────────────────────────────────────────────────────────
|
|
144
|
+
async function main() {
|
|
145
|
+
const transport = new StdioServerTransport();
|
|
146
|
+
await server.connect(transport);
|
|
147
|
+
console.error("NEXT Finance MCP server v0.3.0 iniciado.");
|
|
148
|
+
}
|
|
149
|
+
main().catch((err) => { console.error("Erro fatal:", err); process.exit(1); });
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Servidor HTTP local para login via browser.
|
|
3
|
+
* Abre uma janela no browser com formulário de e-mail/senha.
|
|
4
|
+
* As credenciais NUNCA passam pelo chat.
|
|
5
|
+
*/
|
|
6
|
+
export type LoginFn = (email: string, senha: string) => Promise<Record<string, unknown>>;
|
|
7
|
+
/**
|
|
8
|
+
* Inicia servidor HTTP local, abre o browser e aguarda o usuário fazer login.
|
|
9
|
+
* Resolve quando o login for bem-sucedido ou expirar (2 min).
|
|
10
|
+
*/
|
|
11
|
+
export declare function openLoginUI(loginFn: LoginFn): Promise<Record<string, unknown>>;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Servidor HTTP local para login via browser.
|
|
3
|
+
* Abre uma janela no browser com formulário de e-mail/senha.
|
|
4
|
+
* As credenciais NUNCA passam pelo chat.
|
|
5
|
+
*/
|
|
6
|
+
import * as http from "http";
|
|
7
|
+
import * as os from "os";
|
|
8
|
+
import { exec } from "child_process";
|
|
9
|
+
const LOGIN_HTML = `<!DOCTYPE html>
|
|
10
|
+
<html lang="pt-br">
|
|
11
|
+
<head>
|
|
12
|
+
<meta charset="utf-8">
|
|
13
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
14
|
+
<title>NEXT Finance — Login</title>
|
|
15
|
+
<style>
|
|
16
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
17
|
+
body {
|
|
18
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
19
|
+
background: #f0f2f5;
|
|
20
|
+
display: flex; align-items: center; justify-content: center;
|
|
21
|
+
min-height: 100vh;
|
|
22
|
+
}
|
|
23
|
+
.card {
|
|
24
|
+
background: #fff;
|
|
25
|
+
border-radius: 12px;
|
|
26
|
+
box-shadow: 0 4px 24px rgba(0,0,0,.10);
|
|
27
|
+
padding: 40px 36px 32px;
|
|
28
|
+
width: 360px;
|
|
29
|
+
}
|
|
30
|
+
.logo {
|
|
31
|
+
text-align: center;
|
|
32
|
+
margin-bottom: 28px;
|
|
33
|
+
}
|
|
34
|
+
.logo h1 { font-size: 22px; color: #1a73e8; font-weight: 700; letter-spacing: -0.5px; }
|
|
35
|
+
.logo p { font-size: 13px; color: #666; margin-top: 4px; }
|
|
36
|
+
label { display: block; font-size: 13px; color: #444; margin-bottom: 6px; font-weight: 500; }
|
|
37
|
+
input[type=email], input[type=password] {
|
|
38
|
+
width: 100%; padding: 11px 14px; border: 1.5px solid #ddd;
|
|
39
|
+
border-radius: 8px; font-size: 14px; outline: none;
|
|
40
|
+
transition: border-color .2s;
|
|
41
|
+
}
|
|
42
|
+
input:focus { border-color: #1a73e8; }
|
|
43
|
+
.field { margin-bottom: 18px; }
|
|
44
|
+
.btn {
|
|
45
|
+
width: 100%; padding: 12px; background: #1a73e8; color: #fff;
|
|
46
|
+
border: none; border-radius: 8px; font-size: 15px; font-weight: 600;
|
|
47
|
+
cursor: pointer; transition: background .2s;
|
|
48
|
+
}
|
|
49
|
+
.btn:hover { background: #1557b0; }
|
|
50
|
+
.btn:disabled { background: #aaa; cursor: default; }
|
|
51
|
+
.error {
|
|
52
|
+
background: #fdecea; color: #c62828; border-radius: 8px;
|
|
53
|
+
padding: 10px 14px; font-size: 13px; margin-bottom: 16px; display: none;
|
|
54
|
+
}
|
|
55
|
+
.secure {
|
|
56
|
+
text-align: center; font-size: 12px; color: #888; margin-top: 18px;
|
|
57
|
+
}
|
|
58
|
+
.secure span { color: #2e7d32; font-weight: 600; }
|
|
59
|
+
</style>
|
|
60
|
+
</head>
|
|
61
|
+
<body>
|
|
62
|
+
<div class="card">
|
|
63
|
+
<div class="logo">
|
|
64
|
+
<h1>NEXT Finance</h1>
|
|
65
|
+
<p>Entre com suas credenciais para continuar</p>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="error" id="err"></div>
|
|
68
|
+
<form id="form" method="POST" action="/login">
|
|
69
|
+
<div class="field">
|
|
70
|
+
<label for="email">E-mail</label>
|
|
71
|
+
<input type="email" id="email" name="email" placeholder="seu@email.com" required autofocus>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="field">
|
|
74
|
+
<label for="senha">Senha</label>
|
|
75
|
+
<input type="password" id="senha" name="senha" placeholder="••••••••" required>
|
|
76
|
+
</div>
|
|
77
|
+
<button class="btn" type="submit" id="btn">Entrar</button>
|
|
78
|
+
</form>
|
|
79
|
+
<p class="secure"><span>🔒 Conexão local</span> — suas credenciais não passam pelo chat</p>
|
|
80
|
+
</div>
|
|
81
|
+
<script>
|
|
82
|
+
document.getElementById("form").addEventListener("submit", async (e) => {
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
const btn = document.getElementById("btn");
|
|
85
|
+
const err = document.getElementById("err");
|
|
86
|
+
btn.disabled = true;
|
|
87
|
+
btn.textContent = "Autenticando…";
|
|
88
|
+
err.style.display = "none";
|
|
89
|
+
|
|
90
|
+
const data = new URLSearchParams(new FormData(e.target));
|
|
91
|
+
const res = await fetch("/login", { method: "POST", body: data });
|
|
92
|
+
const json = await res.json();
|
|
93
|
+
|
|
94
|
+
if (json.sucesso) {
|
|
95
|
+
window.location.href = "/sucesso";
|
|
96
|
+
} else {
|
|
97
|
+
err.textContent = json.mensagem;
|
|
98
|
+
err.style.display = "block";
|
|
99
|
+
btn.disabled = false;
|
|
100
|
+
btn.textContent = "Entrar";
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
</script>
|
|
104
|
+
</body>
|
|
105
|
+
</html>`;
|
|
106
|
+
const SUCCESS_HTML = `<!DOCTYPE html>
|
|
107
|
+
<html lang="pt-br">
|
|
108
|
+
<head>
|
|
109
|
+
<meta charset="utf-8">
|
|
110
|
+
<title>Login realizado — NEXT Finance</title>
|
|
111
|
+
<style>
|
|
112
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
113
|
+
body {
|
|
114
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
115
|
+
background: #f0f2f5; display: flex; align-items: center;
|
|
116
|
+
justify-content: center; min-height: 100vh;
|
|
117
|
+
}
|
|
118
|
+
.card {
|
|
119
|
+
background: #fff; border-radius: 12px;
|
|
120
|
+
box-shadow: 0 4px 24px rgba(0,0,0,.10);
|
|
121
|
+
padding: 48px 36px; width: 360px; text-align: center;
|
|
122
|
+
}
|
|
123
|
+
.icon { font-size: 56px; margin-bottom: 16px; }
|
|
124
|
+
h2 { color: #2e7d32; font-size: 20px; margin-bottom: 10px; }
|
|
125
|
+
p { color: #555; font-size: 14px; }
|
|
126
|
+
</style>
|
|
127
|
+
</head>
|
|
128
|
+
<body>
|
|
129
|
+
<div class="card">
|
|
130
|
+
<div class="icon">✅</div>
|
|
131
|
+
<h2>Login realizado com sucesso!</h2>
|
|
132
|
+
<p>Pode fechar esta janela e voltar ao Claude.</p>
|
|
133
|
+
</div>
|
|
134
|
+
</body>
|
|
135
|
+
</html>`;
|
|
136
|
+
/** Abre o browser no macOS/Linux/Windows */
|
|
137
|
+
function openBrowser(url) {
|
|
138
|
+
const platform = os.platform();
|
|
139
|
+
const cmd = platform === "darwin" ? `open "${url}"` :
|
|
140
|
+
platform === "win32" ? `start "" "${url}"` :
|
|
141
|
+
`xdg-open "${url}"`;
|
|
142
|
+
exec(cmd, (err) => {
|
|
143
|
+
if (err)
|
|
144
|
+
console.error("Não foi possível abrir o browser:", err.message);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Inicia servidor HTTP local, abre o browser e aguarda o usuário fazer login.
|
|
149
|
+
* Resolve quando o login for bem-sucedido ou expirar (2 min).
|
|
150
|
+
*/
|
|
151
|
+
export function openLoginUI(loginFn) {
|
|
152
|
+
return new Promise((resolve) => {
|
|
153
|
+
let resolved = false;
|
|
154
|
+
const server = http.createServer(async (req, res) => {
|
|
155
|
+
const url = req.url ?? "/";
|
|
156
|
+
// Página inicial → formulário
|
|
157
|
+
if (req.method === "GET" && url === "/") {
|
|
158
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
159
|
+
res.end(LOGIN_HTML);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// Página de sucesso
|
|
163
|
+
if (req.method === "GET" && url === "/sucesso") {
|
|
164
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
165
|
+
res.end(SUCCESS_HTML);
|
|
166
|
+
setTimeout(() => server.close(), 500);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// POST de login
|
|
170
|
+
if (req.method === "POST" && url === "/login") {
|
|
171
|
+
let body = "";
|
|
172
|
+
req.on("data", (chunk) => (body += chunk));
|
|
173
|
+
req.on("end", async () => {
|
|
174
|
+
const params = new URLSearchParams(body);
|
|
175
|
+
const email = params.get("email") ?? "";
|
|
176
|
+
const senha = params.get("senha") ?? "";
|
|
177
|
+
const result = await loginFn(email, senha);
|
|
178
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
179
|
+
res.end(JSON.stringify(result));
|
|
180
|
+
if (result["sucesso"] && !resolved) {
|
|
181
|
+
resolved = true;
|
|
182
|
+
resolve(result);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
res.writeHead(404);
|
|
188
|
+
res.end();
|
|
189
|
+
});
|
|
190
|
+
server.listen(0, "127.0.0.1", () => {
|
|
191
|
+
const addr = server.address();
|
|
192
|
+
const url = `http://127.0.0.1:${addr.port}`;
|
|
193
|
+
console.error(`[NEXT Finance MCP] Abrindo login em ${url}`);
|
|
194
|
+
openBrowser(url);
|
|
195
|
+
});
|
|
196
|
+
// Timeout de 2 minutos
|
|
197
|
+
setTimeout(() => {
|
|
198
|
+
if (!resolved) {
|
|
199
|
+
resolved = true;
|
|
200
|
+
server.close();
|
|
201
|
+
resolve({ sucesso: false, mensagem: "Tempo de login expirado (2 min). Tente novamente." });
|
|
202
|
+
}
|
|
203
|
+
}, 120_000);
|
|
204
|
+
});
|
|
205
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "next-finance-mcp",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "MCP Server para o NEXT Finance (finance.net.br) — login via browser, listagem de carteiras, contas e lançamentos",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"next-finance-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"dev": "tsx src/index.ts",
|
|
16
|
+
"start": "node dist/index.js",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"mcp",
|
|
21
|
+
"model-context-protocol",
|
|
22
|
+
"next-finance",
|
|
23
|
+
"finance",
|
|
24
|
+
"erp",
|
|
25
|
+
"claude",
|
|
26
|
+
"anthropic"
|
|
27
|
+
],
|
|
28
|
+
"author": "Rodrigo Antônio de Paiva <rodrigo@paiva.com.br>",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/rodrigopaiva/next-finance-mcp"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22.0.0",
|
|
42
|
+
"tsx": "^4.0.0",
|
|
43
|
+
"typescript": "^5.0.0"
|
|
44
|
+
}
|
|
45
|
+
}
|