import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { WaveClient } from "../services/wave-client.js"; import { WaveInvoice } from "../types.js"; import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from "../constants.js"; interface InvoicesData { business: { invoices: { pageInfo: { currentPage: number; totalPages: number; totalCount: number }; edges: Array<{ node: WaveInvoice }>; }; }; } interface InvoiceData { business: { invoice: WaveInvoice }; } interface InvoiceCreateData { invoiceCreate: { didSucceed: boolean; inputErrors: Array<{ code: string; message: string; path: string[] }>; invoice?: WaveInvoice; }; } interface InvoicePatchData { invoicePatch: { didSucceed: boolean; inputErrors: Array<{ code: string; message: string; path: string[] }>; invoice?: WaveInvoice; }; } interface InvoiceActionData { invoiceApprove?: { didSucceed: boolean; inputErrors: Array<{ message: string }> }; invoiceSend?: { didSucceed: boolean; inputErrors: Array<{ message: string }>; invoice?: { id: string } }; invoiceMarkSent?: { didSucceed: boolean; inputErrors: Array<{ message: string }> }; invoiceDelete?: { didSucceed: boolean; inputErrors: Array<{ message: string }> }; } const INVOICE_FIELDS = ` id pdfUrl viewUrl status title subhead invoiceNumber invoiceDate dueDate memo customer { id name email } currency { code } amountDue { value currency { code } } amountPaid { value currency { code } } taxTotal { value currency { code } } subtotal { value currency { code } } total { value currency { code } } items { product { id name } description quantity unitPrice subtotal total account { id name } } createdAt modifiedAt `; function formatInvoiceMarkdown(inv: WaveInvoice): string[] { const lines = [ `## Invoice #${inv.invoiceNumber} — ${inv.customer?.name ?? "Unknown"} (${inv.id})`, `- **Status**: ${inv.status}`, `- **Date**: ${inv.invoiceDate ?? "N/A"}`, `- **Due**: ${inv.dueDate ?? "N/A"}`, `- **Total**: ${WaveClient.formatCurrency(inv.total?.value ?? 0, inv.currency?.code ?? "")}`, `- **Amount Due**: ${WaveClient.formatCurrency(inv.amountDue?.value ?? 0, inv.currency?.code ?? "")}`, `- **Amount Paid**: ${WaveClient.formatCurrency(inv.amountPaid?.value ?? 0, inv.currency?.code ?? "")}`, ]; if (inv.pdfUrl) lines.push(`- **PDF**: ${inv.pdfUrl}`); if (inv.viewUrl) lines.push(`- **View**: ${inv.viewUrl}`); lines.push(""); return lines; } export function registerInvoiceTools(server: McpServer, client: WaveClient): void { // ── wave_list_invoices ──────────────────────────────────────────────────── server.registerTool( "wave_list_invoices", { title: "List Wave Invoices", description: `List invoices for a Wave business with optional filtering by status or customer. Args: - businessId (string): Wave business ID - page (number): Page number (default: 1) - pageSize (number): Items per page, 1-100 (default: 20) - customerId (string): Filter by customer ID (optional) - invoiceNumber (string): Filter by invoice number (optional) - status ('DRAFT' | 'APPROVED' | 'SENT' | 'VIEWED' | 'PARTIALLY_PAID' | 'PAID' | 'OVERDUE'): Filter by status (optional) - response_format ('markdown' | 'json'): Output format (default: 'markdown') Returns: Paginated list of invoices with totals, status, and customer info.`, inputSchema: z.object({ businessId: z.string().describe("Wave business ID"), page: z.number().int().min(1).default(1).describe("Page number"), pageSize: z.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE).describe("Items per page"), customerId: z.string().optional().describe("Filter by customer ID"), invoiceNumber: z.string().optional().describe("Filter by invoice number"), status: z.enum(["DRAFT", "APPROVED", "SENT", "VIEWED", "PARTIALLY_PAID", "PAID", "OVERDUE"]).optional().describe("Filter by status"), response_format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"), }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, async ({ businessId, page, pageSize, customerId, invoiceNumber, status, response_format }) => { try { // Build optional filter variables const vars: Record = { businessId, page, pageSize }; if (customerId) vars.customerId = customerId; if (invoiceNumber) vars.invoiceNumber = invoiceNumber; if (status) vars.status = status; const data = await client.query(` query($businessId: ID!, $page: Int!, $pageSize: Int!) { business(id: $businessId) { invoices(page: $page, pageSize: $pageSize) { pageInfo { currentPage totalPages totalCount } edges { node { ${INVOICE_FIELDS} } } } } } `, vars); let { pageInfo, edges } = data.business.invoices; let invoices = edges.map((e) => e.node); // Client-side filter since Wave API may not support all filter params at query level if (customerId) invoices = invoices.filter((i) => i.customer?.id === customerId); if (status) invoices = invoices.filter((i) => i.status === status); if (invoiceNumber) invoices = invoices.filter((i) => i.invoiceNumber?.includes(invoiceNumber)); if (!invoices.length) { return { content: [{ type: "text", text: "No invoices found matching the criteria." }] }; } const result = { total: pageInfo.totalCount, count: invoices.length, page: pageInfo.currentPage, totalPages: pageInfo.totalPages, has_more: pageInfo.currentPage < pageInfo.totalPages, next_page: pageInfo.currentPage < pageInfo.totalPages ? pageInfo.currentPage + 1 : undefined, invoices, }; let text: string; if (response_format === "json") { text = JSON.stringify(result, null, 2); } else { const lines = [`# Invoices (${pageInfo.totalCount} total, page ${pageInfo.currentPage}/${pageInfo.totalPages})`, ""]; for (const inv of invoices) lines.push(...formatInvoiceMarkdown(inv)); text = lines.join("\n"); } return { content: [{ type: "text", text: WaveClient.truncate(text) }], structuredContent: result, }; } catch (error) { return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; } } ); // ── wave_get_invoice ────────────────────────────────────────────────────── server.registerTool( "wave_get_invoice", { title: "Get Wave Invoice", description: `Get full details for a specific invoice including line items. Args: - businessId (string): Wave business ID - invoiceId (string): Invoice ID - response_format ('markdown' | 'json'): Output format (default: 'markdown') Returns: Full invoice with all line items, totals, dates, and PDF/view links.`, inputSchema: z.object({ businessId: z.string().describe("Wave business ID"), invoiceId: z.string().describe("Invoice ID"), response_format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"), }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, async ({ businessId, invoiceId, response_format }) => { try { const data = await client.query(` query($businessId: ID!, $invoiceId: ID!) { business(id: $businessId) { invoice(id: $invoiceId) { ${INVOICE_FIELDS} } } } `, { businessId, invoiceId }); const inv = data.business.invoice; if (!inv) return { content: [{ type: "text", text: `No invoice found with ID: ${invoiceId}` }] }; let text: string; if (response_format === "json") { text = JSON.stringify(inv, null, 2); } else { const lines = [...formatInvoiceMarkdown(inv)]; if (inv.items?.length) { lines.push("### Line Items", ""); for (const item of inv.items) { const desc = item.description ?? item.product?.name ?? "Item"; lines.push(`- ${desc}: ${item.quantity} × ${WaveClient.formatCurrency(item.unitPrice, inv.currency?.code ?? "")} = ${WaveClient.formatCurrency(item.total, inv.currency?.code ?? "")}`); } } text = lines.join("\n"); } return { content: [{ type: "text", text }], structuredContent: inv }; } catch (error) { return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; } } ); // ── wave_create_invoice ─────────────────────────────────────────────────── server.registerTool( "wave_create_invoice", { title: "Create Wave Invoice", description: `Create a new draft invoice in Wave. Args: - businessId (string): Wave business ID - customerId (string): Customer ID for the invoice - invoiceDate (string): Invoice date in YYYY-MM-DD format (optional, defaults to today) - dueDate (string): Due date in YYYY-MM-DD format (optional) - invoiceNumber (string): Custom invoice number (optional, auto-assigned if omitted) - memo (string): Note to appear on invoice (optional) - items (array): Line items (required, at least 1) - productId (string): Product ID (optional) - description (string): Item description - quantity (number): Quantity - unitPrice (number): Unit price - accountId (string): Revenue account ID (optional) - response_format ('markdown' | 'json'): Output format (default: 'markdown') Returns: Created invoice details or validation errors.`, inputSchema: z.object({ businessId: z.string().describe("Wave business ID"), customerId: z.string().describe("Customer ID"), invoiceDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Invoice date (YYYY-MM-DD)"), dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Due date (YYYY-MM-DD)"), invoiceNumber: z.string().optional().describe("Custom invoice number"), memo: z.string().optional().describe("Memo/note on invoice"), items: z.array(z.object({ productId: z.string().optional().describe("Product ID"), description: z.string().optional().describe("Line item description"), quantity: z.number().min(0.001).describe("Quantity"), unitPrice: z.number().describe("Unit price"), accountId: z.string().optional().describe("Revenue account ID"), })).min(1).describe("Line items (at least 1 required)"), response_format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"), }), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, }, async ({ businessId, customerId, invoiceDate, dueDate, invoiceNumber, memo, items, response_format }) => { try { const input: Record = { businessId, customerId, items: items.map((item) => { const li: Record = { quantity: item.quantity, unitPrice: item.unitPrice, }; if (item.productId) li.productId = item.productId; if (item.description) li.description = item.description; if (item.accountId) li.accountId = item.accountId; return li; }), }; if (invoiceDate) input.invoiceDate = invoiceDate; if (dueDate) input.dueDate = dueDate; if (invoiceNumber) input.invoiceNumber = invoiceNumber; if (memo) input.memo = memo; const data = await client.query(` mutation($input: InvoiceCreateInput!) { invoiceCreate(input: $input) { didSucceed inputErrors { code message path } invoice { ${INVOICE_FIELDS} } } } `, { input }); const result = data.invoiceCreate; if (!result.didSucceed) { const errs = result.inputErrors.map((e) => ` - ${e.path?.join(".") ?? "field"}: ${e.message}`).join("\n"); return { content: [{ type: "text", text: `Failed to create invoice:\n${errs}` }] }; } let text: string; if (response_format === "json") { text = JSON.stringify(result.invoice, null, 2); } else { text = `Invoice created successfully!\n\n${formatInvoiceMarkdown(result.invoice!).join("\n")}`; } return { content: [{ type: "text", text }], structuredContent: result.invoice }; } catch (error) { return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; } } ); // ── wave_approve_invoice ────────────────────────────────────────────────── server.registerTool( "wave_approve_invoice", { title: "Approve Wave Invoice", description: `Approve a draft invoice, moving it to APPROVED status so it can be sent. Args: - invoiceId (string): Invoice ID to approve Returns: Success confirmation or error details.`, inputSchema: z.object({ invoiceId: z.string().describe("Invoice ID to approve"), }), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, async ({ invoiceId }) => { try { const data = await client.query(` mutation($input: InvoiceApproveInput!) { invoiceApprove(input: $input) { didSucceed inputErrors { message } } } `, { input: { invoiceId } }); const result = data.invoiceApprove!; if (!result.didSucceed) { const errs = result.inputErrors.map((e) => ` - ${e.message}`).join("\n"); return { content: [{ type: "text", text: `Failed to approve invoice:\n${errs}` }] }; } return { content: [{ type: "text", text: `Invoice ${invoiceId} approved successfully.` }] }; } catch (error) { return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; } } ); // ── wave_send_invoice ───────────────────────────────────────────────────── server.registerTool( "wave_send_invoice", { title: "Send Wave Invoice", description: `Send an approved invoice to the customer via email. Args: - invoiceId (string): Invoice ID to send - to (array of strings): Recipient email addresses - subject (string): Email subject (optional) - message (string): Email message body (optional) Returns: Success confirmation or error details.`, inputSchema: z.object({ invoiceId: z.string().describe("Invoice ID to send"), to: z.array(z.string().email()).min(1).describe("Recipient email addresses"), subject: z.string().optional().describe("Email subject line"), message: z.string().optional().describe("Email message body"), }), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, }, async ({ invoiceId, to, subject, message }) => { try { const input: Record = { invoiceId, to }; if (subject) input.subject = subject; if (message) input.message = message; const data = await client.query(` mutation($input: InvoiceSendInput!) { invoiceSend(input: $input) { didSucceed inputErrors { message } } } `, { input }); const result = data.invoiceSend!; if (!result.didSucceed) { const errs = result.inputErrors.map((e) => ` - ${e.message}`).join("\n"); return { content: [{ type: "text", text: `Failed to send invoice:\n${errs}` }] }; } return { content: [{ type: "text", text: `Invoice ${invoiceId} sent to ${to.join(", ")}.` }] }; } catch (error) { return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; } } ); // ── wave_delete_invoice ─────────────────────────────────────────────────── server.registerTool( "wave_delete_invoice", { title: "Delete Wave Invoice", description: `Delete an invoice. Only DRAFT invoices can be deleted; paid invoices cannot be deleted. ⚠️ This action is irreversible. Args: - businessId (string): Wave business ID - invoiceId (string): Invoice ID to delete Returns: Success confirmation or error details.`, inputSchema: z.object({ businessId: z.string().describe("Wave business ID"), invoiceId: z.string().describe("Invoice ID to delete"), }), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, }, async ({ businessId, invoiceId }) => { try { const data = await client.query(` mutation($input: InvoiceDeleteInput!) { invoiceDelete(input: $input) { didSucceed inputErrors { message } } } `, { input: { businessId, invoiceId } }); const result = data.invoiceDelete!; if (!result.didSucceed) { const errs = result.inputErrors.map((e) => ` - ${e.message}`).join("\n"); return { content: [{ type: "text", text: `Failed to delete invoice:\n${errs}` }] }; } return { content: [{ type: "text", text: `Invoice ${invoiceId} deleted successfully.` }] }; } catch (error) { return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; } } ); }