From bbf22665901f59787830232408f0ecf5236a9f57 Mon Sep 17 00:00:00 2001 From: zgaetano Date: Tue, 31 Mar 2026 15:29:47 -0400 Subject: [PATCH] Add mcp-gateway/wave-mcp/src/tools/products.ts --- mcp-gateway/wave-mcp/src/tools/products.ts | 321 +++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 mcp-gateway/wave-mcp/src/tools/products.ts diff --git a/mcp-gateway/wave-mcp/src/tools/products.ts b/mcp-gateway/wave-mcp/src/tools/products.ts new file mode 100644 index 0000000..0d9c7db --- /dev/null +++ b/mcp-gateway/wave-mcp/src/tools/products.ts @@ -0,0 +1,321 @@ +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)}` }] }; + } + } + ); +}