Add mcp-gateway/wave-mcp/src/tools/accounting.ts

This commit is contained in:
Zac Gaetano 2026-03-31 15:29:46 -04:00
parent d3db917469
commit 9beb92cc51

View file

@ -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<UserData>(`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<AccountsData>(`
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<string, WaveAccount[]> = {};
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<string, unknown> = {
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<TransactionCreateData>(`
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)}` }] };
}
}
);
}