From 506c3aaf27dffdb3afd4a411b72f50f191f50e20 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/customers.ts --- mcp-gateway/wave-mcp/src/tools/customers.ts | 394 ++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 mcp-gateway/wave-mcp/src/tools/customers.ts diff --git a/mcp-gateway/wave-mcp/src/tools/customers.ts b/mcp-gateway/wave-mcp/src/tools/customers.ts new file mode 100644 index 0000000..699ee37 --- /dev/null +++ b/mcp-gateway/wave-mcp/src/tools/customers.ts @@ -0,0 +1,394 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { WaveClient } from "../services/wave-client.js"; +import { WaveCustomer } from "../types.js"; +import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from "../constants.js"; + +interface CustomersData { + business: { + customers: { + pageInfo: { currentPage: number; totalPages: number; totalCount: number }; + edges: Array<{ node: WaveCustomer }>; + }; + }; +} + +interface CustomerData { + business: { customer: WaveCustomer }; +} + +interface CustomerCreateData { + customerCreate: { + didSucceed: boolean; + inputErrors: Array<{ code: string; message: string; path: string[] }>; + customer?: WaveCustomer; + }; +} + +interface CustomerPatchData { + customerPatch: { + didSucceed: boolean; + inputErrors: Array<{ code: string; message: string; path: string[] }>; + customer?: WaveCustomer; + }; +} + +interface CustomerDeleteData { + customerDelete: { + didSucceed: boolean; + inputErrors: Array<{ code: string; message: string; path: string[] }>; + }; +} + +const CUSTOMER_FIELDS = ` + id name firstName lastName email + currency { code } + address { + addressLine1 city postalCode + country { name code } + } + createdAt modifiedAt +`; + +function formatCustomerMarkdown(c: WaveCustomer): string[] { + const lines = [`## ${c.name} (${c.id})`]; + if (c.email) lines.push(`- **Email**: ${c.email}`); + if (c.currency?.code) lines.push(`- **Currency**: ${c.currency.code}`); + if (c.address?.city) { + const loc = [c.address.city, c.address.country?.code].filter(Boolean).join(", "); + lines.push(`- **Location**: ${loc}`); + } + lines.push(`- **Created**: ${c.createdAt?.slice(0, 10) ?? "unknown"}`); + lines.push(""); + return lines; +} + +export function registerCustomerTools(server: McpServer, client: WaveClient): void { + // ── wave_list_customers ─────────────────────────────────────────────────── + + server.registerTool( + "wave_list_customers", + { + title: "List Wave Customers", + description: `List customers for a Wave business with optional sorting and pagination. + +Args: + - businessId (string): Wave business ID + - page (number): Page number (default: 1) + - pageSize (number): Items per page, 1-100 (default: 20) + - sort (string): Sort field — 'NAME' or 'CREATED_AT' (default: 'NAME') + - sortDirection ('ASC' | 'DESC'): Sort direction (default: 'ASC') + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + Paginated list of customers with id, name, email, currency, and address.`, + 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"), + sort: z.enum(["NAME", "CREATED_AT"]).default("NAME").describe("Sort field"), + sortDirection: z.enum(["ASC", "DESC"]).default("ASC").describe("Sort direction"), + response_format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"), + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async ({ businessId, page, pageSize, sort, sortDirection, response_format }) => { + try { + const data = await client.query(` + query($businessId: ID!, $page: Int!, $pageSize: Int!, $sort: CustomerSort!, $sortDirection: SortDirection!) { + business(id: $businessId) { + customers(page: $page, pageSize: $pageSize, sort: $sort, sortDirection: $sortDirection) { + pageInfo { currentPage totalPages totalCount } + edges { node { ${CUSTOMER_FIELDS} } } + } + } + } + `, { businessId, page, pageSize, sort, sortDirection }); + + const { pageInfo, edges } = data.business.customers; + const customers = edges.map((e) => e.node); + + if (!customers.length) { + return { content: [{ type: "text", text: "No customers found." }] }; + } + + const result = { + total: pageInfo.totalCount, + count: customers.length, + page: pageInfo.currentPage, + totalPages: pageInfo.totalPages, + has_more: pageInfo.currentPage < pageInfo.totalPages, + next_page: pageInfo.currentPage < pageInfo.totalPages ? pageInfo.currentPage + 1 : undefined, + customers, + }; + + let text: string; + if (response_format === "json") { + text = JSON.stringify(result, null, 2); + } else { + const lines = [ + `# Customers (${pageInfo.totalCount} total, page ${pageInfo.currentPage}/${pageInfo.totalPages})`, + "", + ]; + for (const c of customers) lines.push(...formatCustomerMarkdown(c)); + 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_get_customer ───────────────────────────────────────────────────── + + server.registerTool( + "wave_get_customer", + { + title: "Get Wave Customer", + description: `Get details for a specific customer by ID. + +Args: + - businessId (string): Wave business ID + - customerId (string): Customer ID + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + Full customer details including contact info and address.`, + inputSchema: z.object({ + businessId: z.string().describe("Wave business ID"), + customerId: z.string().describe("Customer ID"), + response_format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"), + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async ({ businessId, customerId, response_format }) => { + try { + const data = await client.query(` + query($businessId: ID!, $customerId: ID!) { + business(id: $businessId) { + customer(id: $customerId) { ${CUSTOMER_FIELDS} } + } + } + `, { businessId, customerId }); + + const c = data.business.customer; + if (!c) return { content: [{ type: "text", text: `No customer found with ID: ${customerId}` }] }; + + let text: string; + if (response_format === "json") { + text = JSON.stringify(c, null, 2); + } else { + text = formatCustomerMarkdown(c).join("\n"); + } + + return { content: [{ type: "text", text }], structuredContent: c }; + } catch (error) { + return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; + } + } + ); + + // ── wave_create_customer ────────────────────────────────────────────────── + + server.registerTool( + "wave_create_customer", + { + title: "Create Wave Customer", + description: `Create a new customer in a Wave business. + +Args: + - businessId (string): Wave business ID + - name (string): Customer display name (required) + - firstName (string): First name (optional) + - lastName (string): Last name (optional) + - email (string): Email address (optional) + - currencyCode (string): ISO 4217 currency code, e.g. 'USD' (optional) + - addressLine1 (string): Street address (optional) + - city (string): City (optional) + - postalCode (string): Postal/ZIP code (optional) + - countryCode (string): ISO 3166-1 alpha-2 country code, e.g. 'US' (optional) + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + Created customer details or validation errors.`, + inputSchema: z.object({ + businessId: z.string().describe("Wave business ID"), + name: z.string().min(1).describe("Customer display name"), + firstName: z.string().optional().describe("First name"), + lastName: z.string().optional().describe("Last name"), + email: z.string().email().optional().describe("Email address"), + currencyCode: z.string().length(3).optional().describe("ISO 4217 currency code (e.g. 'USD')"), + addressLine1: z.string().optional().describe("Street address"), + city: z.string().optional().describe("City"), + postalCode: z.string().optional().describe("Postal/ZIP code"), + countryCode: z.string().length(2).optional().describe("ISO 3166-1 alpha-2 country code (e.g. 'US')"), + response_format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"), + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + }, + async ({ businessId, name, firstName, lastName, email, currencyCode, addressLine1, city, postalCode, countryCode, response_format }) => { + try { + const input: Record = { businessId, name }; + if (firstName) input.firstName = firstName; + if (lastName) input.lastName = lastName; + if (email) input.email = email; + if (currencyCode) input.currency = currencyCode; + if (addressLine1 || city || postalCode || countryCode) { + const address: Record = {}; + if (addressLine1) address.addressLine1 = addressLine1; + if (city) address.city = city; + if (postalCode) address.postalCode = postalCode; + if (countryCode) address.countryCode = countryCode; + input.address = address; + } + + const data = await client.query(` + mutation($input: CustomerCreateInput!) { + customerCreate(input: $input) { + didSucceed + inputErrors { code message path } + customer { ${CUSTOMER_FIELDS} } + } + } + `, { input }); + + const result = data.customerCreate; + + 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 customer:\n${errs}` }] }; + } + + let text: string; + if (response_format === "json") { + text = JSON.stringify(result.customer, null, 2); + } else { + text = `Customer created successfully!\n\n${formatCustomerMarkdown(result.customer!).join("\n")}`; + } + + return { content: [{ type: "text", text }], structuredContent: result.customer }; + } catch (error) { + return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; + } + } + ); + + // ── wave_update_customer ────────────────────────────────────────────────── + + server.registerTool( + "wave_update_customer", + { + title: "Update Wave Customer", + description: `Update an existing customer's details. Only provide fields you want to change. + +Args: + - businessId (string): Wave business ID + - customerId (string): Customer ID to update + - name (string): New display name (optional) + - firstName (string): New first name (optional) + - lastName (string): New last name (optional) + - email (string): New email address (optional) + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + Updated customer details or validation errors.`, + inputSchema: z.object({ + businessId: z.string().describe("Wave business ID"), + customerId: z.string().describe("Customer ID to update"), + name: z.string().min(1).optional().describe("New display name"), + firstName: z.string().optional().describe("New first name"), + lastName: z.string().optional().describe("New last name"), + email: z.string().email().optional().describe("New email address"), + response_format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"), + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async ({ businessId, customerId, name, firstName, lastName, email, response_format }) => { + try { + const input: Record = { businessId, customerId }; + if (name) input.name = name; + if (firstName !== undefined) input.firstName = firstName; + if (lastName !== undefined) input.lastName = lastName; + if (email !== undefined) input.email = email; + + const data = await client.query(` + mutation($input: CustomerPatchInput!) { + customerPatch(input: $input) { + didSucceed + inputErrors { code message path } + customer { ${CUSTOMER_FIELDS} } + } + } + `, { input }); + + const result = data.customerPatch; + 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 customer:\n${errs}` }] }; + } + + let text: string; + if (response_format === "json") { + text = JSON.stringify(result.customer, null, 2); + } else { + text = `Customer updated successfully!\n\n${formatCustomerMarkdown(result.customer!).join("\n")}`; + } + + return { content: [{ type: "text", text }], structuredContent: result.customer }; + } catch (error) { + return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; + } + } + ); + + // ── wave_delete_customer ────────────────────────────────────────────────── + + server.registerTool( + "wave_delete_customer", + { + title: "Delete Wave Customer", + description: `Permanently delete a customer from a Wave business. + +⚠️ This action is irreversible. Customers with existing invoices may not be deletable. + +Args: + - businessId (string): Wave business ID + - customerId (string): Customer ID to delete + +Returns: + Success confirmation or error details.`, + inputSchema: z.object({ + businessId: z.string().describe("Wave business ID"), + customerId: z.string().describe("Customer ID to delete"), + }), + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, + }, + async ({ businessId, customerId }) => { + try { + const data = await client.query(` + mutation($input: CustomerDeleteInput!) { + customerDelete(input: $input) { + didSucceed + inputErrors { code message path } + } + } + `, { input: { businessId, customerId } }); + + const result = data.customerDelete; + if (!result.didSucceed) { + const errs = result.inputErrors.map((e) => ` - ${e.message}`).join("\n"); + return { content: [{ type: "text", text: `Failed to delete customer:\n${errs}` }] }; + } + + return { content: [{ type: "text", text: `Customer ${customerId} deleted successfully.` }] }; + } catch (error) { + return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; + } + } + ); +}