Skip to main content
Glama

Money Lover MCP Server

by ferdhika31
server.js13.6 kB
import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; import { MoneyloverClient, MoneyloverApiError } from './moneyloverClient.js'; import { readToken, writeToken, removeToken } from './tokenCache.js'; const DIRECT_TOKEN_ENV_KEYS = ['MONEYLOVER_TOKEN', 'MONEY_LOVER_TOKEN']; const ENV_FILE_DISABLE_FLAG = 'MONEYLOVER_MCP_DISABLE_ENV_FILE'; const ENV_FILE_PATH_ENV = 'MONEYLOVER_MCP_ENV_FILE'; let envFileLoaded = false; const normalizeEnvValue = value => { if (!value) { return ''; } let trimmed = value.trim(); if ( (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")) ) { trimmed = trimmed.slice(1, -1); } return trimmed .replaceAll('\\n', '\n') .replaceAll('\\r', '\r') .replaceAll('\\t', '\t') .replaceAll('\\\\', '\\'); }; const applyEnvFile = raw => { if (!raw) { return; } const lines = raw.split(/\r?\n/); for (const line of lines) { if (!line || line.startsWith('#')) { continue; } const separatorIndex = line.indexOf('='); if (separatorIndex <= 0) { continue; } const key = line.slice(0, separatorIndex).trim(); if (!key || typeof process.env[key] !== 'undefined') { continue; } const value = normalizeEnvValue(line.slice(separatorIndex + 1)); process.env[key] = value; } }; const loadEnvFileIfNeeded = () => { if (envFileLoaded) { return; } envFileLoaded = true; if (process.env[ENV_FILE_DISABLE_FLAG] === '1') { return; } const candidates = []; const customPath = process.env[ENV_FILE_PATH_ENV]?.trim(); if (customPath) { candidates.push(customPath); } try { const moduleDir = path.dirname(fileURLToPath(import.meta.url)); candidates.push(path.resolve(moduleDir, '..', '.env')); } catch (error) { console.warn('Failed to resolve module directory for env loading:', error); } candidates.push(path.resolve(process.cwd(), '.env')); const visited = new Set(); for (const candidate of candidates) { const normalized = candidate ? path.resolve(candidate) : ''; if (!normalized || visited.has(normalized)) { continue; } visited.add(normalized); try { const raw = fs.readFileSync(normalized, 'utf8'); applyEnvFile(raw); break; } catch (error) { if (error?.code !== 'ENOENT') { console.warn('Failed to load environment file', normalized, error); } } } }; const getEnvConfig = () => { loadEnvFileIfNeeded(); const email = process.env.EMAIL?.trim() ?? ''; const password = process.env.PASSWORD?.trim() ?? ''; const directToken = DIRECT_TOKEN_ENV_KEYS.map(key => process.env[key]?.trim()).find(Boolean) ?? ''; return { email, password, directToken }; }; let cachedEnvEmail = ''; let cachedEnvToken = ''; let envTokenPromise = null; let cacheLoaded = false; let cachedEnvUsesDirectToken = false; const formatSuccess = data => ({ content: [ { type: 'text', text: JSON.stringify(data, null, 2) } ], structuredContent: data }); const formatError = error => { const base = { error: error.name, message: error.message }; if (typeof error.code !== 'undefined' && error.code !== null) { base.code = error.code; } if (error.detail) { base.detail = error.detail; } return { content: [ { type: 'text', text: error.message } ], structuredContent: base, isError: true }; }; const withClient = async (token, fn) => { const client = new MoneyloverClient(token); return fn(client); }; const hasEnvCredentials = () => { const { email, password, directToken } = getEnvConfig(); return Boolean(directToken || (email && password)); }; const fetchEnvToken = async (forceRefresh = false) => { const { email, password, directToken } = getEnvConfig(); if (directToken) { cachedEnvUsesDirectToken = true; cachedEnvEmail = email; cachedEnvToken = directToken; envTokenPromise = null; cacheLoaded = true; return directToken; } if (cachedEnvUsesDirectToken) { cachedEnvUsesDirectToken = false; cachedEnvToken = ''; envTokenPromise = null; cacheLoaded = false; } if (!email || !password) { return null; } if (email !== cachedEnvEmail) { cachedEnvEmail = email; cachedEnvToken = ''; envTokenPromise = null; cacheLoaded = false; } if (forceRefresh) { cachedEnvToken = ''; envTokenPromise = null; cacheLoaded = false; try { await removeToken(email); } catch (error) { console.warn('Failed to clear cached Money Lover token:', error); } } if (!cacheLoaded) { try { const storedToken = await readToken(email); if (storedToken) { cachedEnvToken = storedToken; } } catch (error) { console.warn('Failed to read cached Money Lover token:', error); } cacheLoaded = true; } if (cachedEnvToken) { return cachedEnvToken; } if (!envTokenPromise) { envTokenPromise = MoneyloverClient.getToken(email, password) .then(async token => { try { await writeToken(email, token); } catch (error) { console.warn('Failed to persist Money Lover token:', error); } cachedEnvToken = token; cacheLoaded = true; envTokenPromise = null; return token; }) .catch(error => { envTokenPromise = null; throw error; }); } return envTokenPromise; }; const missingTokenError = () => new Error( 'Token is required. Provide a token parameter or set EMAIL/PASSWORD, MONEYLOVER_TOKEN, or a .env file for automatic authentication.' ); const isAuthError = error => { if (!(error instanceof MoneyloverApiError)) { return false; } if (typeof error.code === 'number' && (error.code === 1 || error.code === 401)) { return true; } const message = error.message?.toLowerCase?.() ?? ''; return message.includes('unauth') || message.includes('token'); }; const runWithResolvedToken = async (providedToken, fn) => { let usedEnvToken = false; let token = providedToken; if (!token) { token = await fetchEnvToken(); if (!token) { throw missingTokenError(); } usedEnvToken = true; } try { return await fn(token); } catch (error) { if (usedEnvToken && isAuthError(error)) { const refreshedToken = await fetchEnvToken(true); if (!refreshedToken) { throw error; } return fn(refreshedToken); } throw error; } }; const runWithClient = (token, fn) => runWithResolvedToken(token, resolvedToken => withClient(resolvedToken, fn)); const registerMoneyloverTools = server => { const tokenSchema = z.preprocess(value => { if (typeof value === 'string') { const trimmed = value.trim(); return trimmed === '' ? undefined : trimmed; } return value; }, z.string().min(1).optional()); server.registerTool( 'login', { title: 'Login to Money Lover', description: 'Authenticate using Money Lover credentials to retrieve a JWT token.', inputSchema: { email: z.string().email().describe('Money Lover account email'), password: z.string().min(1).describe('Money Lover account password') }, outputSchema: { token: z.string() } }, async ({ email, password }) => { try { const token = await MoneyloverClient.getToken(email, password); try { await writeToken(email, token); } catch (error) { console.warn('Failed to persist Money Lover token:', error); } const { email: envEmail } = getEnvConfig(); if (email === envEmail && envEmail) { cachedEnvEmail = envEmail; cachedEnvToken = token; cacheLoaded = true; cachedEnvUsesDirectToken = false; } return formatSuccess({ token }); } catch (error) { return formatError(error instanceof Error ? error : new Error(String(error))); } } ); const tokenArgument = { token: tokenSchema.describe( 'JWT token returned by the login tool or derived from EMAIL/PASSWORD environment variables' ) }; server.registerTool( 'get_user_info', { title: 'Get User Info', description: 'Retrieve the Money Lover user profile associated with the provided token.', inputSchema: tokenArgument }, async ({ token }) => { try { const data = await runWithClient(token, client => client.getUserInfo()); return formatSuccess(data ?? {}); } catch (error) { return formatError(error instanceof Error ? error : new Error(String(error))); } } ); server.registerTool( 'get_wallets', { title: 'Get Wallets', description: 'List all wallets accessible to the authenticated user.', inputSchema: tokenArgument, outputSchema: { wallets: z.array(z.record(z.any())) } }, async ({ token }) => { try { const wallets = (await runWithClient(token, client => client.getWallets())) ?? []; return formatSuccess({ wallets }); } catch (error) { return formatError(error instanceof Error ? error : new Error(String(error))); } } ); server.registerTool( 'get_categories', { title: 'Get Categories', description: 'Retrieve categories for a specific wallet.', inputSchema: { ...tokenArgument, walletId: z.string().min(1).describe('Wallet identifier') }, outputSchema: { categories: z.array(z.record(z.any())) } }, async ({ token, walletId }) => { try { const data = (await runWithClient(token, client => client.getCategories(walletId))) ?? []; return formatSuccess({ categories: data }); } catch (error) { return formatError(error instanceof Error ? error : new Error(String(error))); } } ); server.registerTool( 'get_transactions', { title: 'Get Transactions', description: 'Fetch transactions for a wallet between two dates.', inputSchema: { ...tokenArgument, walletId: z.string().min(1).describe('Wallet identifier'), startDate: z .string() .regex(/\d{4}-\d{2}-\d{2}/) .describe('Start date in YYYY-MM-DD format'), endDate: z .string() .regex(/\d{4}-\d{2}-\d{2}/) .describe('End date in YYYY-MM-DD format') } }, async ({ token, walletId, startDate, endDate }) => { try { const data = await runWithClient(token, client => client.getTransactions(walletId, startDate, endDate)); return formatSuccess(data ?? {}); } catch (error) { return formatError(error instanceof Error ? error : new Error(String(error))); } } ); server.registerTool( 'add_transaction', { title: 'Add Transaction', description: 'Create a new transaction in a wallet.', inputSchema: { ...tokenArgument, walletId: z.string().min(1).describe('Wallet identifier'), categoryId: z.string().min(1).describe('Category identifier'), amount: z.string().min(1).describe('Transaction amount as string'), note: z.string().optional().describe('Optional transaction note'), date: z .string() .regex(/\d{4}-\d{2}-\d{2}/) .describe('Display date in YYYY-MM-DD format'), with: z .array(z.string()) .optional() .describe('Optional array of related parties') } }, async ({ token, ...payload }) => { try { const data = await runWithClient(token, client => client.addTransaction({ walletId: payload.walletId, categoryId: payload.categoryId, amount: payload.amount, note: payload.note, date: payload.date, with: payload.with }) ); return formatSuccess(data ?? {}); } catch (error) { return formatError(error instanceof Error ? error : new Error(String(error))); } } ); }; export const createMoneyloverServer = () => { const server = new McpServer({ name: 'moneylover-mcp-server', version: '0.0.1' }); registerMoneyloverTools(server); return server; }; export const startMoneyloverServer = async () => { const server = createMoneyloverServer(); const transport = new StdioServerTransport(); await server.connect(transport); return { server, transport }; }; export const __test = { hasEnvCredentials, fetchEnvToken, runWithResolvedToken, runWithClient, clearEnvTokenCache: () => { cachedEnvEmail = ''; cachedEnvToken = ''; envTokenPromise = null; cacheLoaded = false; cachedEnvUsesDirectToken = false; envFileLoaded = false; } }; if (import.meta.url === `file://${process.argv[1]}`) { startMoneyloverServer().catch(error => { const err = error instanceof Error ? error : new Error(String(error)); console.error('Money Lover MCP server failed to start:', err.message); if (err instanceof MoneyloverApiError && err.detail) { console.error('Detail:', JSON.stringify(err.detail)); } process.exitCode = 1; }); }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ferdhika31/moneylover-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server