Skip to main content
Glama

Moneybird MCP Server

MIT License
39
18
  • Apple
index.ts20.5 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { ListToolsRequestSchema, ListPromptsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { MoneybirdClient } from './services/moneybird.js'; import dotenv from 'dotenv'; import { z } from 'zod'; import { isMoneybirdError, MoneybirdRateLimitError } from './common/errors.js'; import { createInterface } from 'readline'; import { VERSION } from './common/version.js'; import { MoneybirdContact } from './operations/contacts.js'; import { MoneybirdInvoice } from './operations/invoices.js'; import { ListContactsSchema } from './operations/contacts.js'; import { listContacts } from './operations/contacts.js'; // Initialize environment variables dotenv.config(); // Required environment variables const requiredEnvVars = [ 'MONEYBIRD_API_TOKEN', 'MONEYBIRD_ADMINISTRATION_ID' ]; // Check for required environment variables for (const envVar of requiredEnvVars) { if (!process.env[envVar]) { console.error(`Missing required environment variable: ${envVar}`); process.exit(1); } } // Initialize Moneybird client const moneybirdClient = new MoneybirdClient( process.env.MONEYBIRD_API_TOKEN!, process.env.MONEYBIRD_ADMINISTRATION_ID! ); // Create MCP server with longer timeouts const server = new Server({ name: 'moneybird-mcp-server', version: `v${VERSION.toString()}`, description: 'MCP server for interacting with Moneybird API', contactEmail: process.env.CONTACT_EMAIL || 'vanderheijden86@gmail.com', autoImplementResourcesList: true, }, { capabilities: { tools: {}, prompts: {} }, }); // Format Moneybird errors for better readability function formatMoneybirdError(error: any): string { let message = `Moneybird API Error: ${error.message}`; if (error.response) { message += `\nStatus: ${error.response.status}`; if (error.response.data) { message += `\nDetails: ${JSON.stringify(error.response.data)}`; } } if (error instanceof MoneybirdRateLimitError) { message = `Rate Limit Exceeded: ${error.message}`; if (error.resetAt) { message += `\nResets at: ${error.resetAt.toISOString()}`; } } return message; } // Debug JSON-RPC messages function setupStdioDebugger() { const rl = createInterface({ input: process.stdin, terminal: false }); // Monitor incoming messages rl.on('line', (line) => { try { // Only log if it looks like JSON if (line.trim().startsWith('{')) { JSON.parse(line); console.error(`RECEIVED: ${line}`); } } catch (e) { // Not JSON or other error, ignore } }); // Override stdout.write to monitor outgoing messages const originalStdoutWrite = process.stdout.write; // @ts-ignore - We need to override this for debugging process.stdout.write = function (chunk: string | Uint8Array, encoding?: BufferEncoding, callback?: (err?: Error) => void): boolean { if (typeof chunk === 'string' && chunk.trim().startsWith('{')) { try { // Check if it's JSON JSON.parse(chunk); console.error(`SENDING: ${chunk}`); } catch (e) { // Not JSON, ignore } } return originalStdoutWrite.call(this, chunk, encoding, callback); }; } // Add this function to the top of your file function progressUpdates(operation: string): NodeJS.Timeout { const startTime = Date.now(); return setInterval(() => { console.error(`Still working on ${operation}... (${Math.floor((Date.now() - startTime) / 1000)}s elapsed)`); }, 2000); // Send progress message every 2 seconds } // Add a utility function to chunk large responses function chunkResponse(data: any, maxSize: number = 100): any[] { if (!Array.isArray(data) || data.length <= maxSize) { return [data]; } const chunks = []; for (let i = 0; i < data.length; i += maxSize) { chunks.push(data.slice(i, i + maxSize)); } return chunks; } // At the top level of your file let lastSuccessfulOperation = Date.now(); let consecutiveErrors = 0; // Add this function function updateHealthMetrics(success: boolean) { if (success) { lastSuccessfulOperation = Date.now(); consecutiveErrors = 0; } else { consecutiveErrors++; console.error(`Health metrics: ${consecutiveErrors} consecutive errors`); } } // Define available tools handler (resources/list endpoint) server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "list_contacts", description: "List all contacts from your Moneybird account with optional filtering", inputSchema: { type: "object", properties: { page: {type: "number", description: "Page number for pagination"}, perPage: {type: "number", description: "Items per page"}, query: {type: "string", description: "General search term across contact fields"}, filter: {type: "string", description: "Specific filter in format 'property:value' (e.g., 'created_after:2023-01-01 00:00:00 UTC', 'updated_after:2023-01-01', 'first_name:value')"}, include_archived: {type: "boolean", description: "Include archived contacts in results"}, todo: {type: "string", description: "Filter contacts based on outstanding tasks"} }, additionalProperties: false } }, { name: "get_contact", description: "Get a specific contact by ID from your Moneybird account", inputSchema: { type: "object", properties: { contact_id: {type: "string", description: "The ID of the contact to retrieve"} }, required: ["contact_id"], additionalProperties: false } }, { name: "list_sales_invoices", description: "List all sales invoices from your Moneybird account", inputSchema: { type: "object", properties: { page: {type: "number", description: "Page number for pagination"}, perPage: {type: "number", description: "Items per page"} }, additionalProperties: false } }, { name: "get_sales_invoice", description: "Get a specific sales invoice by ID from your Moneybird account", inputSchema: { type: "object", properties: { invoice_id: {type: "string", description: "The ID of the sales invoice to retrieve"} }, required: ["invoice_id"], additionalProperties: false } }, { name: "list_financial_accounts", description: "List all financial accounts from your Moneybird account", inputSchema: { type: "object", properties: {}, additionalProperties: false } }, { name: "list_products", description: "List all products from your Moneybird account", inputSchema: { type: "object", properties: {}, additionalProperties: false } }, { name: "list_projects", description: "List all projects from your Moneybird account", inputSchema: { type: "object", properties: {}, additionalProperties: false } }, { name: "list_time_entries", description: "List all time entries from your Moneybird account", inputSchema: { type: "object", properties: {}, additionalProperties: false } }, { name: "moneybird_request", description: "Make a custom request to the Moneybird API", inputSchema: { type: "object", properties: { method: { type: "string", enum: ["get", "post", "put", "delete"], description: "HTTP method for the request" }, path: { type: "string", description: "API path (without administration ID prefix)" }, data: { description: "Request data for POST and PUT requests (optional)" } }, required: ["method", "path"], additionalProperties: false } }, { name: "moneybird_assistant", description: "Get assistance with using the Moneybird MCP server", inputSchema: { type: "object", properties: {}, additionalProperties: false } } ] }; }); // Handle tool execution server.setRequestHandler(CallToolRequestSchema, async (request) => { const {name, arguments: args} = request.params; console.error(`Executing tool: ${name} with args:`, args); try { switch (name) { case "list_contacts": { console.error('Fetching contacts from Moneybird...'); const progress = progressUpdates('fetching contacts'); try { // Convert args to the expected format for our listContacts function const options = args as z.infer<typeof ListContactsSchema>; // Log filter information if (options.filter) { console.error(`Applying filter: ${options.filter}`); } if (options.query || options.include_archived || options.todo) { console.error(`Additional parameters: ${JSON.stringify({ query: options.query, include_archived: options.include_archived, todo: options.todo })}`); } const result = await listContacts(options); clearInterval(progress); const contacts = result.contacts; console.error(`Successfully retrieved ${contacts.length} contacts`); // Format contacts for better readability const formattedContacts = contacts.map((contact: MoneybirdContact) => ({ id: contact.id, company_name: contact.company_name, firstname: contact.firstname, lastname: contact.lastname, email: contact.email, phone: contact.phone })); // Chunk large responses const chunks = chunkResponse(formattedContacts, 50); if (chunks.length > 1) { return { content: [{ type: "text", text: `Retrieved ${formattedContacts.length} contacts. Showing first ${chunks[0].length}:\n\n${JSON.stringify(chunks[0], null, 2)}\n\n(Response truncated for stability. Use pagination to see more.)` }] }; } // Add filter information to response if filters were applied let responseText = JSON.stringify(formattedContacts, null, 2); if (result.filtered) { responseText = `Filtered contacts with criteria: ${JSON.stringify(result.filterCriteria)}\n\n${responseText}`; } return { content: [{type: "text", text: responseText}] }; } catch (error) { clearInterval(progress); throw error; } } case "get_contact": { const {contact_id} = args as { contact_id: string }; console.error(`Fetching contact with ID: ${contact_id}`); const contact = await moneybirdClient.getContact(contact_id); console.error('Successfully retrieved contact'); return { content: [{type: "text", text: JSON.stringify(contact, null, 2)}] }; } case "list_sales_invoices": { console.error('Fetching sales invoices from Moneybird...'); const invoices = await moneybirdClient.getSalesInvoices(); console.error(`Successfully retrieved ${invoices.length} invoices`); // Format invoices for better readability const formattedInvoices = invoices.map((invoice: MoneybirdInvoice) => ({ id: invoice.id, invoice_id: invoice.invoice_id, contact_id: invoice.contact_id, reference: invoice.reference, state: invoice.state, date: invoice.date, due_date: invoice.due_date, total_price_incl_tax: invoice.total_price_incl_tax, total_price_excl_tax: invoice.total_price_excl_tax, currency: invoice.currency, paid_at: invoice.paid_at })); return { content: [{type: "text", text: JSON.stringify(formattedInvoices, null, 2)}] }; } case "get_sales_invoice": { const {invoice_id} = args as { invoice_id: string }; console.error(`Fetching sales invoice with ID: ${invoice_id}`); const invoice = await moneybirdClient.getSalesInvoice(invoice_id); console.error('Successfully retrieved invoice'); return { content: [{type: "text", text: JSON.stringify(invoice, null, 2)}] }; } case "list_financial_accounts": { console.error('Fetching financial accounts from Moneybird...'); const accounts = await moneybirdClient.getFinancialAccounts(); console.error(`Successfully retrieved ${accounts.length} accounts`); return { content: [{type: "text", text: JSON.stringify(accounts, null, 2)}] }; } case "list_products": { console.error('Fetching products from Moneybird...'); const products = await moneybirdClient.getProducts(); console.error(`Successfully retrieved ${products.length} products`); return { content: [{type: "text", text: JSON.stringify(products, null, 2)}] }; } case "list_projects": { console.error('Fetching projects from Moneybird...'); const projects = await moneybirdClient.getProjects(); console.error(`Successfully retrieved ${projects.length} projects`); return { content: [{type: "text", text: JSON.stringify(projects, null, 2)}] }; } case "list_time_entries": { console.error('Fetching time entries from Moneybird...'); const timeEntries = await moneybirdClient.getTimeEntries(); console.error(`Successfully retrieved ${timeEntries.length} time entries`); return { content: [{type: "text", text: JSON.stringify(timeEntries, null, 2)}] }; } case "moneybird_request": { const {method, path, data} = args as { method: 'get' | 'post' | 'put' | 'delete', path: string, data?: any }; console.error(`Making ${method.toUpperCase()} request to ${path}`); // Parse data if it's a JSON string let processedData = data; if (typeof data === 'string') { try { processedData = JSON.parse(data); } catch (e) { // If it's not valid JSON, keep the original console.error('Warning: Could not parse data as JSON, using as-is'); } } const result = await moneybirdClient.request(method, path, processedData); console.error('Request completed successfully'); return { content: [{type: "text", text: JSON.stringify(result, null, 2)}] }; } case "moneybird_assistant": { return { content: [ { type: "text", text: `You are a financial assistant that helps users with their Moneybird accounting software. You can help them find information about contacts, invoices, financial accounts, and more. Always be helpful, accurate, and professional. Some things you can do: - Look up contact information - Find invoice details - Check financial accounts - List products and services - View project information - Access time entries When users ask for financial information, try to be as specific as possible in your responses.` } ] }; } default: throw new Error(`Tool not found: ${name}`); } } catch (error: any) { console.error(`Error executing tool ${name}:`, error); // Create appropriate error response let errorMessage = `Failed to execute ${name}: ${error.message}`; // Check for specific error types if (isMoneybirdError(error)) { errorMessage = formatMoneybirdError(error); } else if (error.response && error.response.data) { errorMessage += `\nDetails: ${JSON.stringify(error.response.data)}`; } return { content: [{type: "text", text: errorMessage}], isError: true }; } }); // Define available prompts handler (prompts/list endpoint) server.setRequestHandler(ListPromptsRequestSchema, async () => { return { prompts: [{ name: 'moneybird_assistant', description: 'Get assistance with using the Moneybird MCP server', systemPrompt: `You are a financial assistant that helps users with their Moneybird accounting software. You can help them find information about contacts, invoices, financial accounts, and more. Always be helpful, accurate, and professional. Some things you can do: - Look up contact information - Find invoice details - Check financial accounts - List products and services - View project information - Access time entries When users ask for financial information, try to be as specific as possible in your responses.` }] }; }); // Global timeout to detect hanging server (60 seconds) const GLOBAL_TIMEOUT_MS = 60000; let serverInitialized = false; // Start the server async function runServer() { try { // Log version information on startup console.error(`Starting Moneybird MCP Server v${VERSION.toString()}...`); // Set up a global timeout to detect if the server is hanging const globalTimeout = setTimeout(() => { if (!serverInitialized) { console.error("FATAL: Server initialization timeout after " + (GLOBAL_TIMEOUT_MS / 1000) + " seconds. Exiting..."); process.exit(1); } }, GLOBAL_TIMEOUT_MS); // Ensure the timeout doesn't prevent Node.js from exiting globalTimeout.unref(); // Set up debugging for JSON-RPC messages setupStdioDebugger(); // Use console.error for pre-connection logging console.error("Creating StdioServerTransport..."); // Create transport with debugging const transport = new StdioServerTransport(); // Set a timeout to detect hangs during connection const connectionTimeout = setTimeout(() => { console.error("WARNING: Server connection is taking longer than expected. Possible hang detected."); }, 5000); console.error("Connecting server to transport..."); await server.connect(transport); // Clear the timeout since connection succeeded clearTimeout(connectionTimeout); console.error("Server successfully connected to transport"); // Mark server as initialized serverInitialized = true; console.error("Server is now running and waiting for requests"); // Add signal handlers to gracefully exit process.on('SIGINT', () => { console.error("Received SIGINT signal"); console.error("Server shutting down..."); process.exit(0); }); process.on('SIGTERM', () => { console.error("Received SIGTERM signal"); console.error("Server shutting down..."); process.exit(0); }); // Add a periodic health check const healthCheck = setInterval(() => { const idle = Date.now() - lastSuccessfulOperation; if (idle > 5 * 60 * 1000) { // 5 minutes console.error("Warning: No successful operations in last 5 minutes"); } }, 60 * 1000); healthCheck.unref(); // Don't prevent Node from exiting } catch (error) { // Use console.error for errors during startup console.error("Error starting server:", error); process.exit(1); } } // Global error handlers process.on('uncaughtException', (error) => { // Use safe logging that works regardless of server state console.error('Uncaught exception:', error); }); process.on('unhandledRejection', (reason) => { // Use safe logging that works regardless of server state console.error('Unhandled rejection:', reason); }); // Start the server runServer().catch((error) => { // We can't use server.sendLoggingMessage here because if we get an error before // the server is properly initialized, it won't be available console.error("Fatal error in main():", error); process.exit(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/vanderheijden86/moneybird-mcp-server'

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