322 lines
13 KiB
TypeScript
322 lines
13 KiB
TypeScript
|
|
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<ProductsData>(`
|
||
|
|
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<string, unknown> = { 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<ProductCreateData>(`
|
||
|
|
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<string, unknown> = { businessId, productId };
|
||
|
|
if (name) input.name = name;
|
||
|
|
if (description !== undefined) input.description = description;
|
||
|
|
if (unitPrice !== undefined) input.unitPrice = unitPrice;
|
||
|
|
|
||
|
|
const data = await client.query<ProductPatchData>(`
|
||
|
|
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<ProductArchiveData>(`
|
||
|
|
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)}` }] };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
);
|
||
|
|
}
|