Skip to main content
Glama
index.ts11.6 kB
#!/usr/bin/env node /** * MCP Server for Actual Budget * * This server exposes your Actual Budget data to LLMs through the Model Context Protocol, * allowing for natural language interaction with your financial data. * * Features: * - List and view accounts * - View transactions with filtering * - Generate financial statistics and analysis */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import dotenv from 'dotenv'; import express, { NextFunction, Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { parseArgs } from 'node:util'; import { initActualApi, shutdownActualApi } from './actual-api.js'; import { fetchAllAccounts } from './core/data/fetch-accounts.js'; import { setupPrompts } from './prompts.js'; import { setupResources } from './resources.js'; import { setupTools } from './tools/index.js'; import { SetLevelRequestSchema, isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; dotenv.config({ path: '.env' }); // Initialize the MCP server const server = new Server( { name: 'Actual Budget', version: '1.0.0', }, { capabilities: { resources: {}, tools: {}, prompts: {}, logging: {}, }, } ); // Argument parsing const { values: { sse: useSse, 'enable-write': enableWrite, 'enable-bearer': enableBearer, port, 'test-resources': testResources, 'test-custom': testCustom, }, } = parseArgs({ options: { sse: { type: 'boolean', default: false }, 'enable-write': { type: 'boolean', default: false }, 'enable-bearer': { type: 'boolean', default: false }, port: { type: 'string' }, 'test-resources': { type: 'boolean', default: false }, 'test-custom': { type: 'boolean', default: false }, }, allowPositionals: true, }); const resolvedPort = port ? parseInt(port, 10) : 3000; // Bearer authentication middleware const bearerAuth = (req: Request, res: Response, next: NextFunction): void => { if (!enableBearer) { next(); return; } const authHeader = req.headers.authorization; if (!authHeader) { res.status(401).json({ error: 'Authorization header required', }); return; } if (!authHeader.startsWith('Bearer ')) { res.status(401).json({ error: "Authorization header must start with 'Bearer '", }); return; } const token = authHeader.substring(7); // Remove "Bearer " prefix const expectedToken = process.env.BEARER_TOKEN; if (!expectedToken) { console.error('BEARER_TOKEN environment variable not set'); res.status(500).json({ error: 'Server configuration error', }); return; } if (token !== expectedToken) { res.status(401).json({ error: 'Invalid bearer token', }); return; } next(); }; /** * Safely stringify values for logging without throwing on circular structures. */ const safeStringify = (value: unknown): string => { try { return JSON.stringify(value); } catch { return '[unserializable]'; } }; const toErrorMessage = (value: unknown): string => value instanceof Error ? `${value.name}: ${value.message}` : safeStringify(value); // ---------------------------- // SERVER STARTUP // ---------------------------- // Start the server async function main(): Promise<void> { // If testing resources, verify connectivity and list accounts, then exit if (testResources) { console.log('Testing resources...'); try { await initActualApi(); const accounts = await fetchAllAccounts(); console.log(`Found ${accounts.length} account(s).`); accounts.forEach((account) => console.log(`- ${account.id}: ${account.name}`)); console.log('Resource test passed.'); await shutdownActualApi(); process.exit(0); } catch (error) { console.error('Resource test failed:', error); process.exit(1); } } if (testCustom) { console.log('Initializing custom test...'); try { await initActualApi(); // Custom test here // ---------------- console.log('Custom test passed.'); await shutdownActualApi(); process.exit(0); } catch (error) { console.error('Custom test failed:', error); } } // Validate environment variables if (!process.env.ACTUAL_DATA_DIR && !process.env.ACTUAL_SERVER_URL) { console.error('Warning: Neither ACTUAL_DATA_DIR nor ACTUAL_SERVER_URL is set.'); } if (process.env.ACTUAL_SERVER_URL && !process.env.ACTUAL_PASSWORD) { console.error('Warning: ACTUAL_SERVER_URL is set but ACTUAL_PASSWORD is not.'); console.error('If your server requires authentication, initialization will fail.'); } if (useSse) { const app = express(); app.use(express.json()); let transport: SSEServerTransport | null = null; // Log bearer auth status if (enableBearer) { console.error('Bearer authentication enabled for SSE endpoints'); } else { console.error('Bearer authentication disabled - endpoints are public'); } const streamableHttpTransports = new Map<string, StreamableHTTPServerTransport>(); const parseSessionHeader = (value: string | string[] | undefined): string | undefined => { if (!value) { return undefined; } return Array.isArray(value) ? value[0] : value; }; app.get(['/.well-known/oauth-authorization-server', '/.well-known/oauth-authorization-server/sse'], (_req, res) => { res.status(404).json({ error: 'OAuth metadata not configured for this server' }); }); app.get(['/sse/.well-known/oauth-authorization-server'], (_req, res) => { res.status(404).json({ error: 'OAuth metadata not configured for this server' }); }); const handleLegacySse = (req: Request, res: Response): void => { transport = new SSEServerTransport('/messages', res); server.connect(transport).then(() => { console.log = (message: string) => server.sendLoggingMessage({ level: 'info', message }); console.error = (message: string) => server.sendLoggingMessage({ level: 'error', message }); console.error(`Actual Budget MCP Server (SSE) started on port ${resolvedPort}`); }); }; app.get('/sse', bearerAuth, handleLegacySse); const streamablePaths = ['/', '/mcp']; app.all(streamablePaths, bearerAuth, async (req: Request, res: Response) => { const sessionHeader = parseSessionHeader(req.headers['mcp-session-id']); if (req.method === 'GET' && !sessionHeader && req.headers.accept?.includes('text/event-stream')) { handleLegacySse(req, res); return; } const requestLabel = `${req.method} ${req.path}`; try { let streamableTransport = sessionHeader ? streamableHttpTransports.get(sessionHeader) : undefined; if (!streamableTransport) { if (req.method === 'POST' && isInitializeRequest(req.body)) { const remoteAddress = req.ip ?? req.socket.remoteAddress ?? 'unknown'; streamableTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId) => { streamableHttpTransports.set(sessionId, streamableTransport!); console.info(`Streamable HTTP session initialized (session ${sessionId}) from ${remoteAddress}`); }, onsessionclosed: (sessionId) => { streamableHttpTransports.delete(sessionId); console.info(`Streamable HTTP session closed (session ${sessionId})`); }, }); streamableTransport.onclose = () => { const activeSessionId = streamableTransport?.sessionId; if (activeSessionId) { streamableHttpTransports.delete(activeSessionId); console.info(`Streamable HTTP transport closed (session ${activeSessionId})`); } }; try { await server.connect(streamableTransport); console.log = (message: string) => server.sendLoggingMessage({ level: 'info', message }); console.error = (message: string) => server.sendLoggingMessage({ level: 'error', message }); console.error(`Actual Budget MCP Server (Streamable HTTP) started on port ${resolvedPort}`); } catch (error) { console.error(`Failed to connect streamable HTTP transport: ${toErrorMessage(error)}`); res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', }, id: null, }); return; } } else { res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: No valid session ID provided', }, id: null, }); return; } } if (!streamableTransport) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', }, id: null, }); return; } await streamableTransport.handleRequest(req, res, req.body); } catch (error) { console.error(`Streamable HTTP handler error for ${requestLabel}: ${toErrorMessage(error)}`); if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', }, id: null, }); } } }); app.post('/messages', bearerAuth, async (req: Request, res: Response) => { if (transport) { await transport.handlePostMessage(req, res, req.body); } else { res.status(500).json({ error: 'Transport not initialized' }); } }); app.listen(resolvedPort, (error) => { if (error) { console.error('Error:', error); } else { console.error(`Actual Budget MCP Server (SSE) started on port ${resolvedPort}`); } }); } else { const transport = new StdioServerTransport(); await server.connect(transport); console.error('Actual Budget MCP Server (stdio) started'); } } setupResources(server); setupTools(server, enableWrite); setupPrompts(server); server.setRequestHandler(SetLevelRequestSchema, (request) => { console.log(`--- Logging level: ${request.params.level}`); return {}; }); process.on('SIGINT', () => { console.error('SIGINT received, shutting down server'); server.close(); process.exit(0); }); main() .then(() => { if (!useSse) { // TODO: Setup proper logging level change. Messages are available in the notification of MCP Inspector console.log = (message: string) => server.sendLoggingMessage({ level: 'info', message, }); console.error = (message: string) => server.sendLoggingMessage({ level: 'error', message, }); } }) .catch((error: unknown) => { console.error(`Server error: ${toErrorMessage(error)}`); process.exit(1); });

Latest Blog Posts

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/s-stefanov/actual-mcp'

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