395 lines
16 KiB
TypeScript
395 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)}` }] };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
}
|