406 lines
16 KiB
TypeScript
406 lines
16 KiB
TypeScript
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)}` }] };
|
|
}
|
|
}
|
|
);
|
|
}
|