mcp-servers/mcp-gateway/wave-mcp/src/tools/customers.ts

394 lines
16 KiB
TypeScript

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<CustomersData>(`
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<CustomerData>(`
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<string, unknown> = { 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<string, unknown> = {};
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<CustomerCreateData>(`
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<string, unknown> = { 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<CustomerPatchData>(`
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<CustomerDeleteData>(`
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)}` }] };
}
}
);
}