Add wave-mcp/src/tools/accounting.ts
This commit is contained in:
parent
0228ee1f09
commit
3e9a21bab1
1 changed files with 406 additions and 0 deletions
406
wave-mcp/src/tools/accounting.ts
Normal file
406
wave-mcp/src/tools/accounting.ts
Normal 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)}` }] };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue