diff --git a/wave-mcp/src/tools/accounting.ts b/wave-mcp/src/tools/accounting.ts new file mode 100644 index 0000000..5a7921e --- /dev/null +++ b/wave-mcp/src/tools/accounting.ts @@ -0,0 +1,406 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { WaveClient } from "../services/wave-client.js"; +import { WaveAccount, WaveTransaction } from "../types.js"; +import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from "../constants.js"; + +interface AccountsData { + business: { + accounts: { + pageInfo: { currentPage: number; totalPages: number; totalCount: number }; + edges: Array<{ node: WaveAccount }>; + }; + }; +} + +interface TransactionCreateData { + moneyTransactionCreate: { + didSucceed: boolean; + inputErrors: Array<{ code: string; message: string; path: string[] }>; + transaction?: WaveTransaction; + }; +} + +interface UserData { + user: { id: string; defaultEmail: string }; +} + +const ACCOUNT_FIELDS = ` + id name description displayId + type { name value normalBalanceType } + subtype { name value } + currency { code } + isArchived sequence normalBalanceType +`; + +function formatAccountMarkdown(a: WaveAccount): string[] { + const lines = [ + `## ${a.name} (${a.displayId})`, + `- **ID**: ${a.id}`, + `- **Type**: ${a.type.name} / ${a.subtype.name}`, + `- **Currency**: ${a.currency.code}`, + ]; + if (a.isArchived) lines.push("- **Status**: Archived"); + if (a.description) lines.push(`- **Description**: ${a.description}`); + lines.push(""); + return lines; +} + +export function registerAccountingTools(server: McpServer, client: WaveClient): void { + // ── wave_get_user ───────────────────────────────────────────────────────── + + server.registerTool( + "wave_get_user", + { + title: "Get Wave User", + description: `Get the authenticated Wave user's ID and email. Useful for verifying token validity and identifying the logged-in user. + +Args: + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + User ID and default email address.`, + inputSchema: z.object({ + response_format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"), + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async ({ response_format }) => { + try { + const data = await client.query(`query { user { id defaultEmail } }`); + const user = data.user; + + let text: string; + if (response_format === "json") { + text = JSON.stringify(user, null, 2); + } else { + text = `# Wave User\n\n- **ID**: ${user.id}\n- **Email**: ${user.defaultEmail}`; + } + + return { content: [{ type: "text", text }], structuredContent: user }; + } catch (error) { + return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; + } + } + ); + + // ── wave_list_accounts ──────────────────────────────────────────────────── + + server.registerTool( + "wave_list_accounts", + { + title: "List Wave Accounts", + description: `List chart of accounts for a Wave business, optionally filtered by type. + +Args: + - businessId (string): Wave business ID + - page (number): Page number (default: 1) + - pageSize (number): Items per page, 1-100 (default: 20) + - type ('ASSET' | 'LIABILITY' | 'EQUITY' | 'INCOME' | 'EXPENSE'): Filter by account type (optional) + - includeArchived (boolean): Include archived accounts (default: false) + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + Paginated chart of accounts with type, subtype, currency, and display ID.`, + 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"), + type: z.enum(["ASSET", "LIABILITY", "EQUITY", "INCOME", "EXPENSE"]).optional().describe("Filter by account type"), + includeArchived: z.boolean().default(false).describe("Include archived accounts"), + response_format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"), + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async ({ businessId, page, pageSize, type, includeArchived, response_format }) => { + try { + const data = await client.query(` + query($businessId: ID!, $page: Int!, $pageSize: Int!) { + business(id: $businessId) { + accounts(page: $page, pageSize: $pageSize) { + pageInfo { currentPage totalPages totalCount } + edges { node { ${ACCOUNT_FIELDS} } } + } + } + } + `, { businessId, page, pageSize }); + + const { pageInfo, edges } = data.business.accounts; + let accounts = edges.map((e) => e.node); + + // Client-side filtering + if (type) accounts = accounts.filter((a) => a.type.value === type); + if (!includeArchived) accounts = accounts.filter((a) => !a.isArchived); + + if (!accounts.length) { + return { content: [{ type: "text", text: "No accounts found matching the criteria." }] }; + } + + const result = { + total: pageInfo.totalCount, + count: accounts.length, + page: pageInfo.currentPage, + totalPages: pageInfo.totalPages, + has_more: pageInfo.currentPage < pageInfo.totalPages, + next_page: pageInfo.currentPage < pageInfo.totalPages ? pageInfo.currentPage + 1 : undefined, + accounts, + }; + + let text: string; + if (response_format === "json") { + text = JSON.stringify(result, null, 2); + } else { + const lines = [`# Chart of Accounts (${pageInfo.totalCount} total, page ${pageInfo.currentPage}/${pageInfo.totalPages})`, ""]; + // Group by type + const grouped: Record = {}; + for (const a of accounts) { + const t = a.type.name; + if (!grouped[t]) grouped[t] = []; + grouped[t].push(a); + } + for (const [typeName, typeAccounts] of Object.entries(grouped)) { + lines.push(`### ${typeName}`, ""); + for (const a of typeAccounts) lines.push(...formatAccountMarkdown(a)); + } + 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_create_transaction ─────────────────────────────────────────────── + + server.registerTool( + "wave_create_transaction", + { + title: "Create Wave Transaction", + description: `Create a money transaction (deposit or withdrawal) in Wave accounting. + +A transaction requires: +1. An anchor account (typically a bank or credit card account) with a direction +2. One or more line items that categorize the transaction + +The anchor is the primary account (e.g., your bank account). Line items describe what the money was for (e.g., an expense account). + +Direction values: + - For anchor: 'DEPOSIT' (money in) or 'WITHDRAWAL' (money out) + - For line items: 'INCREASE' or 'DECREASE' relative to the account's normal balance + +Args: + - businessId (string): Wave business ID + - description (string): Transaction description/memo + - anchorAccountId (string): Bank or credit card account ID + - anchorAmount (number): Transaction amount (positive) + - anchorDirection ('DEPOSIT' | 'WITHDRAWAL'): Money in or out of anchor account + - anchorDate (string): Date in YYYY-MM-DD format + - lineItems (array): Categorization items + - accountId (string): Account to categorize against + - amount (number): Amount for this line item + - balance ('INCREASE' | 'DECREASE'): Effect on the line item account + - description (string): Line item description (optional) + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + Created transaction details or validation errors.`, + inputSchema: z.object({ + businessId: z.string().describe("Wave business ID"), + description: z.string().optional().describe("Transaction description/memo"), + anchorAccountId: z.string().describe("Bank or credit card account ID"), + anchorAmount: z.number().positive().describe("Transaction amount (positive number)"), + anchorDirection: z.enum(["DEPOSIT", "WITHDRAWAL"]).describe("DEPOSIT = money in, WITHDRAWAL = money out"), + anchorDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Transaction date (YYYY-MM-DD)"), + lineItems: z.array(z.object({ + accountId: z.string().describe("Account ID for categorization"), + amount: z.number().positive().describe("Amount for this line item"), + balance: z.enum(["INCREASE", "DECREASE"]).describe("Effect on this account's balance"), + description: z.string().optional().describe("Line item description"), + })).min(1).describe("Line items categorizing the transaction"), + response_format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"), + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + }, + async ({ businessId, description, anchorAccountId, anchorAmount, anchorDirection, anchorDate, lineItems, response_format }) => { + try { + const input: Record = { + businessId, + externalId: `mcp-${Date.now()}`, + date: anchorDate, + income: anchorDirection === "DEPOSIT" ? { + accountId: anchorAccountId, + amount: anchorAmount, + description, + } : undefined, + expense: anchorDirection === "WITHDRAWAL" ? { + accountId: anchorAccountId, + amount: anchorAmount, + description, + } : undefined, + lineItems: lineItems.map((li) => ({ + accountId: li.accountId, + amount: li.amount, + balance: li.balance, + description: li.description, + })), + }; + + // Remove undefined keys + if (!input.income) delete input.income; + if (!input.expense) delete input.expense; + + const data = await client.query(` + mutation($input: MoneyTransactionCreateInput!) { + moneyTransactionCreate(input: $input) { + didSucceed + inputErrors { code message path } + transaction { + id description notes + createdAt modifiedAt + } + } + } + `, { input }); + + const result = data.moneyTransactionCreate; + 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 transaction:\n${errs}` }] }; + } + + let text: string; + if (response_format === "json") { + text = JSON.stringify(result.transaction, null, 2); + } else { + text = [ + `Transaction created successfully!`, + `- **ID**: ${result.transaction?.id}`, + `- **Description**: ${result.transaction?.description ?? description ?? "N/A"}`, + `- **Amount**: ${WaveClient.formatCurrency(anchorAmount, "")} (${anchorDirection})`, + `- **Date**: ${anchorDate}`, + ].join("\n"); + } + + return { content: [{ type: "text", text }], structuredContent: result.transaction }; + } catch (error) { + return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; + } + } + ); + + // ── wave_list_currencies ────────────────────────────────────────────────── + + server.registerTool( + "wave_list_currencies", + { + title: "List Wave Currencies", + description: `List all currencies supported by Wave (ISO 4217). Useful for finding valid currency codes to use when creating customers or invoices. + +Args: + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + List of all supported currencies with code, symbol, and name.`, + inputSchema: z.object({ + response_format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"), + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async ({ response_format }) => { + try { + const data = await client.query<{ + currencies: Array<{ code: string; symbol: string; name: string; plural: string }>; + }>(` + query { + currencies { + code symbol name plural + } + } + `); + + const currencies = data.currencies; + + let text: string; + if (response_format === "json") { + text = JSON.stringify({ count: currencies.length, currencies }, null, 2); + } else { + const lines = [`# Supported Currencies (${currencies.length})`, ""]; + for (const c of currencies) { + lines.push(`- **${c.code}** ${c.symbol} — ${c.name}`); + } + text = lines.join("\n"); + } + + return { + content: [{ type: "text", text: WaveClient.truncate(text) }], + structuredContent: { count: currencies.length, currencies }, + }; + } catch (error) { + return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; + } + } + ); + + // ── wave_list_countries ─────────────────────────────────────────────────── + + server.registerTool( + "wave_list_countries", + { + title: "List Wave Countries", + description: `List all countries and their provinces supported by Wave. Use this to find valid country and province codes for addresses. + +Args: + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + List of countries with ISO codes and their provinces/states.`, + inputSchema: z.object({ + response_format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"), + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async ({ response_format }) => { + try { + const data = await client.query<{ + countries: Array<{ + code: string; + name: string; + provinces: Array<{ code: string; name: string }>; + }>; + }>(` + query { + countries { + code name + provinces { code name } + } + } + `); + + const countries = data.countries; + + let text: string; + if (response_format === "json") { + text = JSON.stringify({ count: countries.length, countries }, null, 2); + } else { + const lines = [`# Supported Countries (${countries.length})`, ""]; + for (const c of countries) { + lines.push(`- **${c.code}** — ${c.name} (${c.provinces.length} provinces/states)`); + } + text = lines.join("\n"); + } + + return { + content: [{ type: "text", text: WaveClient.truncate(text) }], + structuredContent: { count: countries.length, countries }, + }; + } catch (error) { + return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; + } + } + ); +}