import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { WaveClient } from "../services/wave-client.js"; import { WaveProduct } from "../types.js"; import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from "../constants.js"; interface ProductsData { business: { products: { pageInfo: { currentPage: number; totalPages: number; totalCount: number }; edges: Array<{ node: WaveProduct }>; }; }; } interface ProductCreateData { productCreate: { didSucceed: boolean; inputErrors: Array<{ code: string; message: string; path: string[] }>; product?: WaveProduct; }; } interface ProductPatchData { productPatch: { didSucceed: boolean; inputErrors: Array<{ code: string; message: string; path: string[] }>; product?: WaveProduct; }; } interface ProductArchiveData { productArchive: { didSucceed: boolean; inputErrors: Array<{ code: string; message: string; path: string[] }>; product?: WaveProduct; }; } const PRODUCT_FIELDS = ` id name description unitPrice isSold isBought isArchived currency { code } incomeAccount { id name } expenseAccount { id name } createdAt modifiedAt `; function formatProductMarkdown(p: WaveProduct): string[] { const lines = [`## ${p.name} (${p.id})`]; if (p.description) lines.push(` ${p.description}`); if (p.unitPrice !== undefined && p.unitPrice !== null) { lines.push(`- **Unit Price**: ${WaveClient.formatCurrency(p.unitPrice, p.currency?.code ?? "")}`); } const flags = [p.isSold && "sold", p.isBought && "bought", p.isArchived && "archived"] .filter(Boolean) .join(", "); if (flags) lines.push(`- **Flags**: ${flags}`); if (p.incomeAccount) lines.push(`- **Income Account**: ${p.incomeAccount.name}`); if (p.expenseAccount) lines.push(`- **Expense Account**: ${p.expenseAccount.name}`); lines.push(""); return lines; } export function registerProductTools(server: McpServer, client: WaveClient): void { // ── wave_list_products ──────────────────────────────────────────────────── server.registerTool( "wave_list_products", { title: "List Wave Products", description: `List products/services for a Wave business. Args: - businessId (string): Wave business ID - page (number): Page number (default: 1) - pageSize (number): Items per page, 1-100 (default: 20) - includeArchived (boolean): Include archived products (default: false) - response_format ('markdown' | 'json'): Output format (default: 'markdown') Returns: Paginated list of products with pricing, accounts, and status.`, 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"), includeArchived: z.boolean().default(false).describe("Include archived products"), response_format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"), }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, async ({ businessId, page, pageSize, response_format }) => { try { const data = await client.query(` query($businessId: ID!, $page: Int!, $pageSize: Int!) { business(id: $businessId) { products(page: $page, pageSize: $pageSize) { pageInfo { currentPage totalPages totalCount } edges { node { ${PRODUCT_FIELDS} } } } } } `, { businessId, page, pageSize }); const { pageInfo, edges } = data.business.products; const products = edges.map((e) => e.node); if (!products.length) { return { content: [{ type: "text", text: "No products found." }] }; } const result = { total: pageInfo.totalCount, count: products.length, page: pageInfo.currentPage, totalPages: pageInfo.totalPages, has_more: pageInfo.currentPage < pageInfo.totalPages, next_page: pageInfo.currentPage < pageInfo.totalPages ? pageInfo.currentPage + 1 : undefined, products, }; let text: string; if (response_format === "json") { text = JSON.stringify(result, null, 2); } else { const lines = [`# Products (${pageInfo.totalCount} total, page ${pageInfo.currentPage}/${pageInfo.totalPages})`, ""]; for (const p of products) lines.push(...formatProductMarkdown(p)); 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_product ─────────────────────────────────────────────────── server.registerTool( "wave_create_product", { title: "Create Wave Product", description: `Create a new product or service in a Wave business. Args: - businessId (string): Wave business ID - name (string): Product name (required) - description (string): Product description (optional) - unitPrice (number): Unit price amount (optional) - isSold (boolean): Whether this product is sold to customers (default: true) - isBought (boolean): Whether this product is purchased from suppliers (default: false) - incomeAccountId (string): Account ID for income (optional) - expenseAccountId (string): Account ID for expenses (optional) - response_format ('markdown' | 'json'): Output format (default: 'markdown') Returns: Created product details or validation errors.`, inputSchema: z.object({ businessId: z.string().describe("Wave business ID"), name: z.string().min(1).describe("Product name"), description: z.string().optional().describe("Product description"), unitPrice: z.number().min(0).optional().describe("Unit price"), isSold: z.boolean().default(true).describe("Whether sold to customers"), isBought: z.boolean().default(false).describe("Whether bought from suppliers"), incomeAccountId: z.string().optional().describe("Income account ID"), expenseAccountId: z.string().optional().describe("Expense account ID"), response_format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"), }), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, }, async ({ businessId, name, description, unitPrice, isSold, isBought, incomeAccountId, expenseAccountId, response_format }) => { try { const input: Record = { businessId, name, isSold, isBought }; if (description) input.description = description; if (unitPrice !== undefined) input.unitPrice = unitPrice; if (incomeAccountId) input.incomeAccountId = incomeAccountId; if (expenseAccountId) input.expenseAccountId = expenseAccountId; const data = await client.query(` mutation($input: ProductCreateInput!) { productCreate(input: $input) { didSucceed inputErrors { code message path } product { ${PRODUCT_FIELDS} } } } `, { input }); const result = data.productCreate; 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 product:\n${errs}` }] }; } let text: string; if (response_format === "json") { text = JSON.stringify(result.product, null, 2); } else { text = `Product created successfully!\n\n${formatProductMarkdown(result.product!).join("\n")}`; } return { content: [{ type: "text", text }], structuredContent: result.product }; } catch (error) { return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; } } ); // ── wave_update_product ─────────────────────────────────────────────────── server.registerTool( "wave_update_product", { title: "Update Wave Product", description: `Update an existing product's details. Only provide fields you want to change. Args: - businessId (string): Wave business ID - productId (string): Product ID to update - name (string): New name (optional) - description (string): New description (optional) - unitPrice (number): New unit price (optional) - response_format ('markdown' | 'json'): Output format (default: 'markdown') Returns: Updated product details or validation errors.`, inputSchema: z.object({ businessId: z.string().describe("Wave business ID"), productId: z.string().describe("Product ID to update"), name: z.string().min(1).optional().describe("New name"), description: z.string().optional().describe("New description"), unitPrice: z.number().min(0).optional().describe("New unit price"), response_format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"), }), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, async ({ businessId, productId, name, description, unitPrice, response_format }) => { try { const input: Record = { businessId, productId }; if (name) input.name = name; if (description !== undefined) input.description = description; if (unitPrice !== undefined) input.unitPrice = unitPrice; const data = await client.query(` mutation($input: ProductPatchInput!) { productPatch(input: $input) { didSucceed inputErrors { code message path } product { ${PRODUCT_FIELDS} } } } `, { input }); const result = data.productPatch; if (!result.didSucceed) { const errs = result.inputErrors.map((e) => ` - ${e.path?.join(".") ?? "field"}: ${e.message}`).join("\n"); return { content: [{ type: "text", text: `Failed to update product:\n${errs}` }] }; } let text: string; if (response_format === "json") { text = JSON.stringify(result.product, null, 2); } else { text = `Product updated successfully!\n\n${formatProductMarkdown(result.product!).join("\n")}`; } return { content: [{ type: "text", text }], structuredContent: result.product }; } catch (error) { return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; } } ); // ── wave_archive_product ────────────────────────────────────────────────── server.registerTool( "wave_archive_product", { title: "Archive Wave Product", description: `Archive a product so it no longer appears in active lists. This is reversible via the Wave UI. Args: - businessId (string): Wave business ID - productId (string): Product ID to archive Returns: Confirmation or error details.`, inputSchema: z.object({ businessId: z.string().describe("Wave business ID"), productId: z.string().describe("Product ID to archive"), }), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, async ({ businessId, productId }) => { try { const data = await client.query(` mutation($input: ProductArchiveInput!) { productArchive(input: $input) { didSucceed inputErrors { code message path } product { id name isArchived } } } `, { input: { businessId, productId } }); const result = data.productArchive; if (!result.didSucceed) { const errs = result.inputErrors.map((e) => ` - ${e.message}`).join("\n"); return { content: [{ type: "text", text: `Failed to archive product:\n${errs}` }] }; } return { content: [{ type: "text", text: `Product "${result.product?.name ?? productId}" archived successfully.` }] }; } catch (error) { return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; } } ); }