Add mcp-gateway/wave-mcp/src/tools/customers.ts
This commit is contained in:
parent
41bfe60ef2
commit
506c3aaf27
1 changed files with 394 additions and 0 deletions
394
mcp-gateway/wave-mcp/src/tools/customers.ts
Normal file
394
mcp-gateway/wave-mcp/src/tools/customers.ts
Normal file
|
|
@ -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<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)}` }] };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue