UNPKG

actual-mcp

Version:

Actual Budget MCP server exposing API functionality

286 lines 12.5 kB
#!/usr/bin/env node 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 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 { createServer } from './server.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; // Reason: dotenv@17 (dotenvx) prints to stdout by default, which breaks MCP stdio JSON parsing dotenv.config({ path: '.env', quiet: true }); // 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, res, next) => { 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) => { try { return JSON.stringify(value); } catch { return '[unserializable]'; } }; const toErrorMessage = (value) => value instanceof Error ? `${value.name}: ${value.message}` : safeStringify(value); // ---------------------------- // SERVER STARTUP // ---------------------------- // Start the server async function main() { // 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()); // Log bearer auth status if (enableBearer) { process.stderr.write('Bearer authentication enabled for SSE endpoints\n'); } else { process.stderr.write('Bearer authentication disabled - endpoints are public\n'); } // Per-connection maps for legacy SSE and streamable HTTP const legacySseConnections = new Map(); const streamableSessions = new Map(); const parseSessionHeader = (value) => { 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, res) => { const connectionId = randomUUID(); const connServer = createServer({ enableWrite: !!enableWrite }); const sseTransport = new SSEServerTransport(`/messages?connectionId=${connectionId}`, res); legacySseConnections.set(connectionId, { server: connServer, transport: sseTransport }); connServer.connect(sseTransport).then(() => { process.stderr.write(`Legacy SSE connection established (connectionId ${connectionId})\n`); }); res.on('close', () => { legacySseConnections.delete(connectionId); connServer.close(); }); }; app.get('/sse', bearerAuth, handleLegacySse); const streamablePaths = ['/', '/mcp']; app.all(streamablePaths, bearerAuth, async (req, res) => { 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 session = sessionHeader ? streamableSessions.get(sessionHeader) : undefined; if (!session) { if (req.method === 'POST' && isInitializeRequest(req.body)) { const remoteAddress = req.ip ?? req.socket.remoteAddress ?? 'unknown'; const sessionServer = createServer({ enableWrite: !!enableWrite }); const streamableTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId) => { streamableSessions.set(sessionId, { server: sessionServer, transport: streamableTransport }); console.info(`Streamable HTTP session initialized (session ${sessionId}) from ${remoteAddress}`); }, onsessionclosed: (sessionId) => { streamableSessions.delete(sessionId); sessionServer.close(); console.info(`Streamable HTTP session closed (session ${sessionId})`); }, }); streamableTransport.onclose = () => { const activeSessionId = streamableTransport.sessionId; if (activeSessionId) { streamableSessions.delete(activeSessionId); sessionServer.close(); console.info(`Streamable HTTP transport closed (session ${activeSessionId})`); } }; try { await sessionServer.connect(streamableTransport); process.stderr.write(`Actual Budget MCP Server (Streamable HTTP) started on port ${resolvedPort}\n`); } catch (error) { process.stderr.write(`Failed to connect streamable HTTP transport: ${toErrorMessage(error)}\n`); res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', }, id: null, }); return; } session = { server: sessionServer, transport: streamableTransport }; } else { res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: No valid session ID provided', }, id: null, }); return; } } await session.transport.handleRequest(req, res, req.body); } catch (error) { process.stderr.write(`Streamable HTTP handler error for ${requestLabel}: ${toErrorMessage(error)}\n`); 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, res) => { const connectionId = req.query.connectionId; const conn = connectionId ? legacySseConnections.get(connectionId) : undefined; if (conn) { await conn.transport.handlePostMessage(req, res, req.body); } else { res.status(400).json({ error: 'Invalid or missing connectionId' }); } }); app.listen(resolvedPort, (error) => { if (error) { process.stderr.write(`Error: ${toErrorMessage(error)}\n`); } else { process.stderr.write(`Actual Budget MCP Server (HTTP) listening on port ${resolvedPort}\n`); } }); // SIGINT handler: close all active connections process.on('SIGINT', () => { process.stderr.write('SIGINT received, shutting down server\n'); for (const [, conn] of legacySseConnections) { conn.server.close(); } for (const [, session] of streamableSessions) { session.server.close(); session.transport.close(); } process.exit(0); }); } else { const server = createServer({ enableWrite: !!enableWrite }); const transport = new StdioServerTransport(); await server.connect(transport); console.error('Actual Budget MCP Server (stdio) started'); process.on('SIGINT', () => { console.error('SIGINT received, shutting down server'); server.close(); process.exit(0); }); } } main().catch((error) => { console.error(`Server error: ${toErrorMessage(error)}`); process.exit(1); }); //# sourceMappingURL=index.js.map