From d52b022652a04d1ab4bd1135238c11ddb87e4bae Mon Sep 17 00:00:00 2001 From: Rob Moore Date: Tue, 14 Jul 2026 16:58:36 -0400 Subject: [PATCH 1/2] support rebate offers in MCP --- packages/mcp/src/tools/blueprints.ts | 25 +++++++++++- packages/mcp/tests/tools/blueprints.test.ts | 45 +++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/mcp/src/tools/blueprints.ts b/packages/mcp/src/tools/blueprints.ts index 2e59017..74add7e 100644 --- a/packages/mcp/src/tools/blueprints.ts +++ b/packages/mcp/src/tools/blueprints.ts @@ -152,6 +152,22 @@ function requireOneStepSelector(args: { stepGuid?: unknown; stepIndex?: unknown // update_blueprint_offer (G3). Config fields span all offer types; the server validates them against // the offer's own offerType. Kept as one strict object (not a discriminated union) so offerType can be // omitted for config-only edits and the tool schema stays a plain object. +const rebateFixedAmounts = z + .array( + z + .object({ + currency: z + .string() + .regex(/^[A-Za-z]{3}$/) + .describe('Three-letter invoice currency code.'), + amountMinor: z.number().int().min(0).describe("Fixed rebate in that currency's smallest unit."), + }) + .strict(), + ) + .refine((amounts) => new Set(amounts.map((amount) => amount.currency.toLowerCase())).size === amounts.length, { + message: 'Fixed rebate currencies must be unique.', + }) + const offerConfig = z .object({ couponId: z @@ -171,6 +187,11 @@ const offerConfig = z redirectUrl: z.string().optional().describe('REDIRECT: URL to send the customer to.'), redirectLabel: z.string().optional().describe('REDIRECT: button label.'), options: z.array(z.string()).optional().describe('PLAN_CHANGE: plan/price IDs the customer can switch to.'), + amountType: z.enum(['FIXED', 'PERCENT']).optional().describe('REBATE: fixed or percentage amount.'), + fixedAmounts: rebateFixedAmounts.optional().describe('REBATE: fixed rebate amounts by invoice currency.'), + percentAmount: z.number().int().min(0).max(100).optional().describe('REBATE: percentage of the paid invoice.'), + mbgWindowDays: z.number().int().min(0).optional().describe('REBATE: money-back guarantee window in days.'), + invoiceScope: z.enum(['FIRST_PAID', 'LATEST_PAID']).optional().describe('REBATE: which paid invoice is eligible.'), }) .strict() .describe("Offer-type-specific config. The server validates which fields are allowed against the offer's offerType.") @@ -188,7 +209,7 @@ const updateOfferInput = blueprintIdInput.extend({ .optional() .describe('Edit the offer attached to this structured follow-up option. Requires choiceGuid.'), offerType: z - .enum(['PAUSE', 'DISCOUNT', 'CONTACT', 'PLAN_CHANGE', 'REDIRECT', 'TRIAL_EXTENSION']) + .enum(['PAUSE', 'DISCOUNT', 'CONTACT', 'PLAN_CHANGE', 'REDIRECT', 'TRIAL_EXTENSION', 'REBATE']) .optional() .describe('Change the offer type. Switching type seeds default config for the new type.'), header: z.string().optional().describe('Offer headline. Clears stale offer translations.'), @@ -322,7 +343,7 @@ export function blueprintTools(client: ChurnkeyClient): ToolDefinition[] { description: [ 'Patch the type and functional config of a single offer on a draft blueprint, without sending the full steps array. Offers attach in three places: an offer step (pass stepGuid only), a survey choice (pass stepGuid + choiceGuid), or a structured follow-up option (pass stepGuid + choiceGuid + optionGuid). The offer must already exist at that location. If you pass a published blueprint ID, the API resolves it to the working copy.', '', - "Change offerType and/or its config: DISCOUNT (couponId, or customAmount in cents + customDuration, or autoOptimize), PAUSE (maxPauseLength + pauseInterval, datePicker), TRIAL_EXTENSION (trialExtensionDays), REDIRECT (redirectUrl, redirectLabel), PLAN_CHANGE (options), CONTACT (no config). You may also set header/description. Config is validated against the offer's offerType; switching type seeds default config.", + "Change offerType and/or its config: DISCOUNT (couponId, or customAmount in cents + customDuration, or autoOptimize), PAUSE (maxPauseLength + pauseInterval, datePicker), TRIAL_EXTENSION (trialExtensionDays), REDIRECT (redirectUrl, redirectLabel), PLAN_CHANGE (options), REBATE (fixedAmounts or percentAmount, mbgWindowDays, invoiceScope), CONTACT (no config). You may also set header/description. Config is validated against the offer's offerType; switching type seeds default config.", '', 'Config changes do not affect translations; header/description changes clear stale offer translations (refreshed on publish).', '', diff --git a/packages/mcp/tests/tools/blueprints.test.ts b/packages/mcp/tests/tools/blueprints.test.ts index 72c1c33..13d6925 100644 --- a/packages/mcp/tests/tools/blueprints.test.ts +++ b/packages/mcp/tests/tools/blueprints.test.ts @@ -221,6 +221,51 @@ describe('blueprintTools', () => { ).rejects.toThrow('optionGuid requires choiceGuid.') }) + it('accepts currency-specific rebate configuration', async () => { + const { tool, post } = findTool('update_blueprint_offer') + const input = { + blueprintId: 'bp_123', + stepGuid: 'step_1', + offerType: 'REBATE' as const, + config: { + amountType: 'FIXED' as const, + fixedAmounts: [ + { currency: 'USD', amountMinor: 5000 }, + { currency: 'eur', amountMinor: 4500 }, + ], + mbgWindowDays: 30, + invoiceScope: 'LATEST_PAID' as const, + }, + } + + expect(() => tool.inputSchema.parse(input)).not.toThrow() + await tool.handler(input) + expect(post).toHaveBeenCalledWith('/data/blueprints/bp_123/offer', { + body: { + stepGuid: 'step_1', + offerType: 'REBATE', + config: input.config, + }, + }) + expect(() => + tool.inputSchema.parse({ + ...input, + config: { fixedAmounts: [{ currency: 'US', amountMinor: 5000 }] }, + }), + ).toThrow() + expect(() => + tool.inputSchema.parse({ + ...input, + config: { + fixedAmounts: [ + { currency: 'usd', amountMinor: 5000 }, + { currency: 'USD', amountMinor: 4500 }, + ], + }, + }), + ).toThrow() + }) + it('routes edit_survey_structure with the op body', async () => { const { tool, post } = findTool('edit_survey_structure') From 5e70a66af728363396cb05f28bc51d28b3768385 Mon Sep 17 00:00:00 2001 From: Rob Moore Date: Tue, 14 Jul 2026 19:31:07 -0400 Subject: [PATCH 2/2] normalize MCP rebate currencies --- packages/mcp/src/tools/blueprints.ts | 1 + packages/mcp/tests/tools/blueprints.test.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/mcp/src/tools/blueprints.ts b/packages/mcp/src/tools/blueprints.ts index 74add7e..a46b128 100644 --- a/packages/mcp/src/tools/blueprints.ts +++ b/packages/mcp/src/tools/blueprints.ts @@ -159,6 +159,7 @@ const rebateFixedAmounts = z currency: z .string() .regex(/^[A-Za-z]{3}$/) + .transform((currency) => currency.toLowerCase()) .describe('Three-letter invoice currency code.'), amountMinor: z.number().int().min(0).describe("Fixed rebate in that currency's smallest unit."), }) diff --git a/packages/mcp/tests/tools/blueprints.test.ts b/packages/mcp/tests/tools/blueprints.test.ts index 13d6925..2dfaec2 100644 --- a/packages/mcp/tests/tools/blueprints.test.ts +++ b/packages/mcp/tests/tools/blueprints.test.ts @@ -238,13 +238,19 @@ describe('blueprintTools', () => { }, } - expect(() => tool.inputSchema.parse(input)).not.toThrow() - await tool.handler(input) + const parsed = tool.inputSchema.parse(input) + await tool.handler(parsed) expect(post).toHaveBeenCalledWith('/data/blueprints/bp_123/offer', { body: { stepGuid: 'step_1', offerType: 'REBATE', - config: input.config, + config: { + ...input.config, + fixedAmounts: [ + { currency: 'usd', amountMinor: 5000 }, + { currency: 'eur', amountMinor: 4500 }, + ], + }, }, }) expect(() =>