Skip to main content
Glama
index.js29.9 kB
#!/usr/bin/env node /** * xero-expenses-mcp - MCP server for Xero expense management * * Supports Bills (ACCPAY), Expenses (Bank Transactions), and Expense Claims * Uses PKCE for Desktop app OAuth (no client secret required) */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { XeroClient } from "xero-node"; import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; import { createServer } from "http"; import { URL } from "url"; import open from "open"; import { basename, join } from "path"; import { homedir } from "os"; import { randomBytes, createHash } from "crypto"; // Store tokens in user's home directory const TOKEN_DIR = join(homedir(), ".xero-mcp"); const TOKEN_PATH = join(TOKEN_DIR, "token.json"); // PKCE helpers function generateCodeVerifier() { return randomBytes(32).toString("base64url"); } function generateCodeChallenge(verifier) { return createHash("sha256").update(verifier).digest("base64url"); } class XeroExpensesMCP { constructor() { const redirectUri = process.env.XERO_REDIRECT_URI || "http://localhost:3000/callback"; const clientSecret = process.env.XERO_CLIENT_SECRET; if (!process.env.XERO_CLIENT_ID) { console.error("Error: XERO_CLIENT_ID environment variable is required"); process.exit(1); } // Support both Web app (with secret) and Desktop app (PKCE, no secret) const config = { clientId: process.env.XERO_CLIENT_ID, redirectUris: [redirectUri], scopes: [ "openid", "profile", "email", "accounting.transactions", "accounting.contacts", "accounting.settings.read", "accounting.attachments", "offline_access", ], }; // Only add clientSecret if provided (Web app mode) // Desktop app uses PKCE - no secret needed if (clientSecret) { config.clientSecret = clientSecret; } this.xero = new XeroClient(config); this.tenantId = null; // Ensure token directory exists if (!existsSync(TOKEN_DIR)) { mkdirSync(TOKEN_DIR, { recursive: true }); } } async loadTokens() { if (existsSync(TOKEN_PATH)) { const tokens = JSON.parse(readFileSync(TOKEN_PATH, "utf8")); this.xero.setTokenSet(tokens); const tokenSet = this.xero.readTokenSet(); if (tokenSet && tokenSet.expired && tokenSet.expired()) { await this.xero.refreshToken(); this.saveTokens(); } const tenants = await this.xero.updateTenants(); this.tenantId = tenants[0]?.tenantId; return true; } return false; } saveTokens() { const tokenSet = this.xero.readTokenSet(); if (tokenSet) { writeFileSync(TOKEN_PATH, JSON.stringify(tokenSet, null, 2)); } } async authenticate() { const redirectUri = process.env.XERO_REDIRECT_URI || "http://localhost:3000/callback"; const clientId = process.env.XERO_CLIENT_ID; const clientSecret = process.env.XERO_CLIENT_SECRET; const port = 3000; // Generate PKCE values const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); // Build authorization URL const scopes = [ "openid", "profile", "email", "accounting.transactions", "accounting.contacts", "accounting.settings.read", "accounting.attachments", "offline_access" ].join(" "); const authUrl = new URL("https://login.xero.com/identity/connect/authorize"); authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("client_id", clientId); authUrl.searchParams.set("redirect_uri", redirectUri); authUrl.searchParams.set("scope", scopes); authUrl.searchParams.set("code_challenge", codeChallenge); authUrl.searchParams.set("code_challenge_method", "S256"); return new Promise((resolve, reject) => { const server = createServer(async (req, res) => { const url = new URL(req.url, `http://localhost:${port}`); if (url.pathname === "/callback") { const code = url.searchParams.get("code"); if (code) { try { // Exchange code for tokens using PKCE const tokenUrl = "https://identity.xero.com/connect/token"; const body = new URLSearchParams({ grant_type: "authorization_code", code: code, redirect_uri: redirectUri, client_id: clientId, code_verifier: codeVerifier, }); // Add client_secret if available (Web app mode) if (clientSecret) { body.set("client_secret", clientSecret); } const tokenResponse = await fetch(tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), }); if (!tokenResponse.ok) { const errorText = await tokenResponse.text(); throw new Error(`Token exchange failed: ${errorText}`); } const tokens = await tokenResponse.json(); // Set tokens on XeroClient this.xero.setTokenSet(tokens); this.saveTokens(); const tenants = await this.xero.updateTenants(); this.tenantId = tenants[0]?.tenantId; res.writeHead(200, { "Content-Type": "text/html" }); res.end("<h1>Authentication successful!</h1><p>You can close this window.</p>"); server.close(); resolve(true); } catch (error) { res.writeHead(500, { "Content-Type": "text/html" }); res.end(`<h1>Error</h1><p>${error.message}</p>`); server.close(); reject(error); } } } }); server.listen(port, async () => { console.error(`Opening browser for Xero authentication on port ${port}...`); await open(authUrl.toString()); }); setTimeout(() => { server.close(); reject(new Error("Authentication timed out after 5 minutes")); }, 300000); }); } async ensureAuthenticated() { const hasTokens = await this.loadTokens().catch(() => false); if (!hasTokens || !this.tenantId) { await this.authenticate(); } return true; } async listAccounts() { await this.ensureAuthenticated(); const response = await this.xero.accountingApi.getAccounts(this.tenantId); return response.body.accounts .filter(a => a.status === "ACTIVE") .map(a => ({ code: a.code, name: a.name, type: a.type, class: a.class, accountId: a.accountID, })); } async listBankAccounts() { await this.ensureAuthenticated(); const response = await this.xero.accountingApi.getAccounts(this.tenantId); return response.body.accounts .filter(a => a.status === "ACTIVE" && a.type === "BANK") .map(a => ({ accountId: a.accountID, code: a.code, name: a.name, })); } async listContacts(searchTerm) { await this.ensureAuthenticated(); const where = searchTerm ? `Name.Contains("${searchTerm}")` : undefined; const response = await this.xero.accountingApi.getContacts( this.tenantId, undefined, where, undefined, undefined, undefined, undefined, 20 ); return response.body.contacts.map(c => ({ contactId: c.contactID, name: c.name, email: c.emailAddress, })); } async createContact(name, email) { await this.ensureAuthenticated(); const contact = { name, emailAddress: email }; const response = await this.xero.accountingApi.createContacts(this.tenantId, { contacts: [contact] }); const created = response.body.contacts[0]; return { contactId: created.contactID, name: created.name }; } async createBill({ vendorName, vendorEmail, amount, description, accountCode, date, dueDate, reference }) { await this.ensureAuthenticated(); // Find or create contact let contacts = await this.listContacts(vendorName); let contact = contacts.find(c => c.name.toLowerCase() === vendorName.toLowerCase()); if (!contact) { contact = await this.createContact(vendorName, vendorEmail || undefined); } // Create the bill (ACCPAY invoice) const bill = { type: "ACCPAY", contact: { contactID: contact.contactId }, date: date || new Date().toISOString().split("T")[0], dueDate: dueDate || new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0], reference: reference || undefined, lineItems: [ { description: description || "Expense", quantity: 1, unitAmount: amount, accountCode: accountCode || "400", // Default expense account }, ], status: "DRAFT", }; const response = await this.xero.accountingApi.createInvoices(this.tenantId, { invoices: [bill] }); const created = response.body.invoices[0]; return { invoiceId: created.invoiceID, invoiceNumber: created.invoiceNumber, vendor: created.contact.name, total: created.total, status: created.status, date: created.date, }; } async attachFileToBill(invoiceId, filePath) { await this.ensureAuthenticated(); const fileName = basename(filePath); const fileContent = readFileSync(filePath); // Determine mime type from extension const ext = fileName.split('.').pop().toLowerCase(); const mimeTypes = { 'pdf': 'application/pdf', 'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif', }; const mimeType = mimeTypes[ext] || 'application/octet-stream'; const response = await this.xero.accountingApi.createInvoiceAttachmentByFileName( this.tenantId, invoiceId, fileName, fileContent, true, // includeOnline mimeType ); return { attachmentId: response.body.attachments[0]?.attachmentID, fileName: fileName, success: true, }; } async createExpense({ vendorName, vendorEmail, amount, description, accountCode, date, reference, bankAccountId }) { await this.ensureAuthenticated(); // Find or create contact let contacts = await this.listContacts(vendorName); let contact = contacts.find(c => c.name.toLowerCase() === vendorName.toLowerCase()); if (!contact) { contact = await this.createContact(vendorName, vendorEmail || undefined); } // Get bank account - use provided or get first available let bankAccount; if (bankAccountId) { bankAccount = { accountID: bankAccountId }; } else { const bankAccounts = await this.listBankAccounts(); if (bankAccounts.length === 0) { throw new Error("No bank accounts found in Xero. Please create a bank account first."); } bankAccount = { accountID: bankAccounts[0].accountId }; } // Create the expense (Spend Money = BankTransaction with type SPEND) const expense = { type: "SPEND", contact: { contactID: contact.contactId }, bankAccount: bankAccount, date: date || new Date().toISOString().split("T")[0], reference: reference || undefined, lineItems: [ { description: description || "Expense", quantity: 1, unitAmount: amount, accountCode: accountCode || "400", }, ], status: "AUTHORISED", }; const response = await this.xero.accountingApi.createBankTransactions(this.tenantId, { bankTransactions: [expense] }); const created = response.body.bankTransactions[0]; return { bankTransactionId: created.bankTransactionID, vendor: created.contact.name, total: created.total, status: created.status, date: created.date, bankAccount: created.bankAccount?.name, }; } async attachFileToExpense(bankTransactionId, filePath) { await this.ensureAuthenticated(); const fileName = basename(filePath); const fileContent = readFileSync(filePath); // Determine mime type from extension const ext = fileName.split('.').pop().toLowerCase(); const mimeTypes = { 'pdf': 'application/pdf', 'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif', }; const mimeType = mimeTypes[ext] || 'application/octet-stream'; const response = await this.xero.accountingApi.createBankTransactionAttachmentByFileName( this.tenantId, bankTransactionId, fileName, fileContent, mimeType ); return { attachmentId: response.body.attachments[0]?.attachmentID, fileName: fileName, success: true, }; } // Expense Claims functionality (deprecated Feb 2026 but still works) async listUsers() { await this.ensureAuthenticated(); const response = await this.xero.accountingApi.getUsers(this.tenantId); return response.body.users.map(u => ({ userId: u.userID, firstName: u.firstName, lastName: u.lastName, email: u.emailAddress, isSubscriber: u.isSubscriber, })); } async createReceipt({ vendorName, vendorEmail, amount, description, accountCode, date, reference, userId }) { await this.ensureAuthenticated(); // Find or create contact let contacts = await this.listContacts(vendorName); let contact = contacts.find(c => c.name.toLowerCase() === vendorName.toLowerCase()); if (!contact) { contact = await this.createContact(vendorName, vendorEmail || undefined); } // Get the user for the receipt let user; if (userId) { user = { userID: userId }; } else { // Get first user (usually the org owner) const users = await this.listUsers(); if (users.length === 0) { throw new Error("No users found in Xero organization"); } user = { userID: users[0].userId }; } const receipt = { contact: { contactID: contact.contactId }, user: user, date: date || new Date().toISOString().split("T")[0], reference: reference || undefined, lineItems: [ { description: description || "Expense", quantity: 1, unitAmount: amount, accountCode: accountCode || "400", }, ], status: "DRAFT", }; const response = await this.xero.accountingApi.createReceipt(this.tenantId, { receipts: [receipt] }); const created = response.body.receipts[0]; return { receiptId: created.receiptID, receiptNumber: created.receiptNumber, vendor: created.contact?.name, total: created.total, status: created.status, date: created.date, userId: created.user?.userID, }; } async createExpenseClaim({ receiptIds, userId }) { await this.ensureAuthenticated(); // Get user let user; if (userId) { user = { userID: userId }; } else { const users = await this.listUsers(); if (users.length === 0) { throw new Error("No users found in Xero organization"); } user = { userID: users[0].userId }; } // Build receipts array const receipts = receiptIds.map(id => ({ receiptID: id })); const expenseClaim = { user: user, receipts: receipts, status: "SUBMITTED", }; const response = await this.xero.accountingApi.createExpenseClaims( this.tenantId, { expenseClaims: [expenseClaim] } ); const created = response.body.expenseClaims[0]; return { expenseClaimId: created.expenseClaimID, status: created.status, total: created.total, userId: created.user?.userID, receipts: created.receipts?.map(r => r.receiptID), }; } async createExpenseClaimFromReceipt({ vendorName, vendorEmail, amount, description, accountCode, date, reference, userId }) { // Convenience method: creates receipt and expense claim in one go const receipt = await this.createReceipt({ vendorName, vendorEmail, amount, description, accountCode, date, reference, userId }); const claim = await this.createExpenseClaim({ receiptIds: [receipt.receiptId], userId: userId, }); return { expenseClaimId: claim.expenseClaimId, receiptId: receipt.receiptId, vendor: receipt.vendor, total: receipt.total, status: claim.status, date: receipt.date, }; } async createInvoice({ customerName, customerEmail, amount, description, accountCode, date, dueDate, reference }) { await this.ensureAuthenticated(); // Find or create contact let contacts = await this.listContacts(customerName); let contact = contacts.find(c => c.name.toLowerCase() === customerName.toLowerCase()); if (!contact) { contact = await this.createContact(customerName, customerEmail || undefined); } // Create the invoice (ACCREC - accounts receivable) const invoice = { type: "ACCREC", contact: { contactID: contact.contactId }, date: date || new Date().toISOString().split("T")[0], dueDate: dueDate || new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0], reference: reference || undefined, lineItems: [ { description: description || "Services", quantity: 1, unitAmount: amount, accountCode: accountCode || "200", // Default revenue account }, ], status: "DRAFT", }; const response = await this.xero.accountingApi.createInvoices(this.tenantId, { invoices: [invoice] }); const created = response.body.invoices[0]; return { invoiceId: created.invoiceID, invoiceNumber: created.invoiceNumber, customer: created.contact.name, total: created.total, status: created.status, date: created.date, dueDate: created.dueDate, }; } async attachFileToInvoice(invoiceId, filePath) { // Same as attachFileToBill - invoices and bills use same attachment API return this.attachFileToBill(invoiceId, filePath); } async attachFileToReceipt(receiptId, filePath) { await this.ensureAuthenticated(); const fileName = basename(filePath); const fileContent = readFileSync(filePath); const ext = fileName.split('.').pop().toLowerCase(); const mimeTypes = { 'pdf': 'application/pdf', 'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif', }; const mimeType = mimeTypes[ext] || 'application/octet-stream'; // Generate unique idempotency key to avoid conflicts const idempotencyKey = `receipt-${receiptId}-${Date.now()}-${Math.random().toString(36).slice(2)}`; const response = await this.xero.accountingApi.createReceiptAttachmentByFileName( this.tenantId, receiptId, fileName, fileContent, idempotencyKey, { headers: { 'Content-Type': mimeType } } ); return { attachmentId: response.body.attachments[0]?.attachmentID, fileName: fileName, success: true, }; } } // MCP Server setup const xeroExpenses = new XeroExpensesMCP(); const server = new Server( { name: "xero-expenses-mcp", version: "0.1.0" }, { capabilities: { tools: {} } } ); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "xero_list_accounts", description: "List available Xero accounts/categories for expenses", inputSchema: { type: "object", properties: {} }, }, { name: "xero_list_bank_accounts", description: "List available Xero bank accounts for expenses", inputSchema: { type: "object", properties: {} }, }, { name: "xero_list_contacts", description: "Search for vendors/contacts in Xero", inputSchema: { type: "object", properties: { search: { type: "string", description: "Search term for vendor name" }, }, }, }, { name: "xero_create_bill", description: "Create a bill (accounts payable) in Xero - use for invoices you'll pay later", inputSchema: { type: "object", properties: { vendorName: { type: "string", description: "Name of the vendor" }, vendorEmail: { type: "string", description: "Email of the vendor (optional)" }, amount: { type: "number", description: "Total amount of the expense" }, description: { type: "string", description: "Description of the expense" }, accountCode: { type: "string", description: "Xero account code (e.g., '400' for expenses)" }, date: { type: "string", description: "Invoice date (YYYY-MM-DD)" }, dueDate: { type: "string", description: "Due date (YYYY-MM-DD)" }, reference: { type: "string", description: "Reference number from the invoice" }, }, required: ["vendorName", "amount", "description"], }, }, { name: "xero_create_expense", description: "Create a spend money transaction (direct expense) in Xero - use for already-paid expenses like receipts", inputSchema: { type: "object", properties: { vendorName: { type: "string", description: "Name of the vendor" }, vendorEmail: { type: "string", description: "Email of the vendor (optional)" }, amount: { type: "number", description: "Total amount of the expense" }, description: { type: "string", description: "Description of the expense" }, accountCode: { type: "string", description: "Xero expense account code (e.g., '620' for meals)" }, date: { type: "string", description: "Transaction date (YYYY-MM-DD)" }, reference: { type: "string", description: "Reference number from the receipt" }, bankAccountId: { type: "string", description: "Xero bank account ID (optional, uses first available if not specified)" }, }, required: ["vendorName", "amount", "description"], }, }, { name: "xero_attach_file", description: "Attach a file (PDF, image) to an existing Xero bill", inputSchema: { type: "object", properties: { invoiceId: { type: "string", description: "The Xero invoice/bill ID" }, filePath: { type: "string", description: "Path to the file to attach" }, }, required: ["invoiceId", "filePath"], }, }, { name: "xero_attach_file_to_expense", description: "Attach a file (PDF, image) to an existing Xero expense (bank transaction)", inputSchema: { type: "object", properties: { bankTransactionId: { type: "string", description: "The Xero bank transaction ID" }, filePath: { type: "string", description: "Path to the file to attach" }, }, required: ["bankTransactionId", "filePath"], }, }, { name: "xero_list_users", description: "List users in the Xero organization (for expense claims)", inputSchema: { type: "object", properties: {} }, }, { name: "xero_create_expense_claim", description: "Create an expense claim for reimbursement - creates a receipt and submits it as an expense claim (deprecated Feb 2026)", inputSchema: { type: "object", properties: { vendorName: { type: "string", description: "Name of the vendor" }, vendorEmail: { type: "string", description: "Email of the vendor (optional)" }, amount: { type: "number", description: "Total amount of the expense" }, description: { type: "string", description: "Description of the expense" }, accountCode: { type: "string", description: "Xero expense account code (e.g., '620' for meals)" }, date: { type: "string", description: "Receipt date (YYYY-MM-DD)" }, reference: { type: "string", description: "Reference number from the receipt" }, userId: { type: "string", description: "Xero user ID to claim as (optional, uses first user if not specified)" }, }, required: ["vendorName", "amount", "description"], }, }, { name: "xero_attach_file_to_receipt", description: "Attach a file (PDF, image) to an existing Xero receipt", inputSchema: { type: "object", properties: { receiptId: { type: "string", description: "The Xero receipt ID" }, filePath: { type: "string", description: "Path to the file to attach" }, }, required: ["receiptId", "filePath"], }, }, { name: "xero_create_invoice", description: "Create a sales invoice (accounts receivable) in Xero - use for invoices you send to customers", inputSchema: { type: "object", properties: { customerName: { type: "string", description: "Name of the customer" }, customerEmail: { type: "string", description: "Email of the customer (optional)" }, amount: { type: "number", description: "Total amount of the invoice" }, description: { type: "string", description: "Description of the goods/services" }, accountCode: { type: "string", description: "Xero revenue account code (e.g., '200' for sales)" }, date: { type: "string", description: "Invoice date (YYYY-MM-DD)" }, dueDate: { type: "string", description: "Due date (YYYY-MM-DD)" }, reference: { type: "string", description: "Reference or PO number" }, }, required: ["customerName", "amount", "description"], }, }, { name: "xero_attach_file_to_invoice", description: "Attach a file (PDF, image) to an existing Xero invoice", inputSchema: { type: "object", properties: { invoiceId: { type: "string", description: "The Xero invoice ID" }, filePath: { type: "string", description: "Path to the file to attach" }, }, required: ["invoiceId", "filePath"], }, }, ], })); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case "xero_list_accounts": { const accounts = await xeroExpenses.listAccounts(); const expenseAccounts = accounts.filter(a => a.class === "EXPENSE" || a.type === "EXPENSE"); return { content: [{ type: "text", text: JSON.stringify(expenseAccounts, null, 2) }], }; } case "xero_list_bank_accounts": { const accounts = await xeroExpenses.listBankAccounts(); return { content: [{ type: "text", text: JSON.stringify(accounts, null, 2) }], }; } case "xero_list_contacts": { const contacts = await xeroExpenses.listContacts(args.search); return { content: [{ type: "text", text: JSON.stringify(contacts, null, 2) }], }; } case "xero_create_bill": { const result = await xeroExpenses.createBill(args); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; } case "xero_create_expense": { const result = await xeroExpenses.createExpense(args); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; } case "xero_attach_file": { const result = await xeroExpenses.attachFileToBill(args.invoiceId, args.filePath); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; } case "xero_attach_file_to_expense": { const result = await xeroExpenses.attachFileToExpense(args.bankTransactionId, args.filePath); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; } case "xero_list_users": { const users = await xeroExpenses.listUsers(); return { content: [{ type: "text", text: JSON.stringify(users, null, 2) }], }; } case "xero_create_expense_claim": { const result = await xeroExpenses.createExpenseClaimFromReceipt(args); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; } case "xero_attach_file_to_receipt": { const result = await xeroExpenses.attachFileToReceipt(args.receiptId, args.filePath); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; } case "xero_create_invoice": { const result = await xeroExpenses.createInvoice(args); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; } case "xero_attach_file_to_invoice": { const result = await xeroExpenses.attachFileToInvoice(args.invoiceId, args.filePath); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; } default: return { content: [{ type: "text", text: `Unknown tool: ${name}` }] }; } } catch (error) { const errorMsg = error.response?.body ? JSON.stringify(error.response.body, null, 2) : error.message || JSON.stringify(error, null, 2); return { content: [{ type: "text", text: `Error: ${errorMsg}` }], isError: true, }; } }); const transport = new StdioServerTransport(); await server.connect(transport);

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/muness/xero-expenses-mcp'

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