diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 76327fe..b3e73ff 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -6,6 +6,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Expect ### Added +- Restored blueprint/segment tools dropped during the MCP reslice (their Data API endpoints have been live on the server the whole time): `create_blueprint` (default org flow with `empty`/`BASIC`/`B2B`/`MERGEFIELDS` templates), `create_segment_flow` (segment + editable draft in one call — the preferred isolated/test-flow path), `archive_segment` (soft-delete disposable segments), and `list_segment_attributes` (targetable audience attributes with their `valueType`, valid `operands`, and fixed-enum `values`). `update_segment_filter` regains `confirmLiveChange` — editing a live (enabled + published) segment's audience requires `confirmLiveChange: true` (enforced by the API), which the tool previously could not send. +- Segment audience operands corrected to match the Data API: only `INCLUDES`/`NOT_INCLUDES` (STRING/BOOLEAN) and `GTE`/`LTE`/`BETWEEN`/`NOT_BETWEEN` (NUMBER/DATE). `GT`/`LT` are removed — the API rejects them as unrecognized operands, so advertising them produced guaranteed-fail calls. - `get_account` tool (no scope required): identity & session-context preflight — the acting workspace (org), authenticated user, coarse entitlements (active subscription, Intelligence access), granted OAuth scopes, and the **effective live/test mode**. Agents are nudged to call it first to confirm which org and mode they're operating in (and to read their scopes before hitting a guaranteed 403). Backed by the new `GET /v1/data/account`. - Effective-mode visibility: `list_sessions` / `aggregate_sessions` now append the active mode (`Mode: LIVE|TEST`) to their results and carry a mode-sensitivity note in their descriptions — they're the only mode-partitioned surface, so an agent never has to infer live-vs-test from empty data. Configuration, payment-recovery analytics, flow metrics, and DSR are mode-independent and are documented as such. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 3a9f5a0..cef078a 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -14,6 +14,7 @@ Model Context Protocol server for [Churnkey](https://churnkey.co). Lets AI agent | `get_flow_metrics` | Cancel flow performance: sessions, customers saved, save rate, boosted revenue (USD + per-currency), outcomes breakdown, sample-size warning, quotable `summary`, and Feedback AI themes (Intelligence plans). Scope by segment, published blueprint version (compare versions with two calls), or A/B test + date range. | | `list_blueprints` | Current cancel flow inventory for the org: the default flow plus every non-deleted segment flow. Each flow has `status` (`active` / `setup_pending` / `inactive`), `published` and `hasUnpublishedChanges` booleans, `editableBlueprintId`, `publishedBlueprintId`, and compact `draft` / `publishedBlueprint` metadata. Status is a coarse subset of the dashboard badges; the "Unpublished Changes" badge is `status: "active"` + `hasUnpublishedChanges: true`. | | `get_blueprint` | Full cancel flow blueprint by ID, including the `steps` array (each step/offer/survey-choice carries its `guid` for use with `update_blueprint_step`). Large for translated blueprints. | +| `create_blueprint` | Create the default org-level cancel flow draft when the org has none (mirrors dashboard setup). `template: "empty"` makes a blank draft; `BASIC`/`B2B`/`MERGEFIELDS` prepopulate steps and survey choices. Requires `confirm: "create_blueprint"` and writes an audit log. Use `create_segment_flow` for isolated/test flows. | | `update_blueprint_draft` | Draft-only updates for top-level blueprint fields (`name`, `brandImage`, `primaryColor`, `translatedLanguages`). Passing a published blueprint ID edits the corresponding working copy. Writes an audit log. | | `update_blueprint_step` | Sparse draft step edits by `stepGuid` or `stepIndex`, without sending the full `steps` array — step/offer copy, `enabled`, and behavioral config (survey `randomize`/`followupRequired`/`minLength`, freeform/confirm config). Copy edits clear stale translations; publish refreshes translations. Writes an audit log. | | `update_blueprint_offer` | Edit one offer's type and functional config (discount/pause/trial/redirect/plan-change) on a draft, by `stepGuid` (+ optional `choiceGuid`/`optionGuid`). Writes an audit log. | @@ -21,9 +22,12 @@ Model Context Protocol server for [Churnkey](https://churnkey.co). Lets AI agent | `add_blueprint_step` / `remove_blueprint_step` | Add a step at a canonical `place` (server builds the base step) or remove one by `stepGuid` on a draft. Writes an audit log. | | `publish_blueprint` | Publish a draft blueprint as the live org/segment version. Requires `confirm: "publish"` and writes an audit log. | | `list_segments` | Cancel flow segment metadata in priority order (response order = priority; `priority` is the 0-based index). Includes disabled segments (`enabled` flag) and each segment's audience `filter` rules. A/B variant segments appear as separate entries. | +| `list_segment_attributes` | The audience-filter attributes you can target: `builtIn` (the cancel-flow palette, each with `valueType` and applicable `operands`; fixed-enum attributes also list their allowed `values`) plus your org's `custom` attributes. Call before `create_segment_flow` / `update_segment_filter` to target real attributes with valid operands. | +| `create_segment_flow` | Create a new cancel flow segment and its editable draft blueprint in one call (the preferred path for isolated setup/test flows). Optional `blueprint.template` (`empty`/`BASIC`/`B2B`/`MERGEFIELDS`) and initial `segment.filter`. Requires `confirm: "create_segment_flow"`. New flow is `setup_pending` until `publish_blueprint`. | | `reorder_segments` | Reorder cancel flow segment priority. Requires `confirm: "reorder_segments"` and writes an audit log. | +| `archive_segment` | Soft-delete/archive a segment so it leaves the non-deleted inventory (clean up disposable test flows). Segments in unfinished A/B tests can't be archived. Requires `confirm: "archive_segment"` and writes an audit log. | | `set_segment_enabled` | Enable/disable a segment (live targeting on/off). Requires `confirm: "set_segment_enabled"` and writes an audit log. | -| `update_segment_filter` | Replace a segment's audience filter rules (whole-array replace). Requires `confirm: "update_segment_filter"` and writes an audit log. | +| `update_segment_filter` | Replace a segment's audience filter rules (whole-array replace). Requires `confirm: "update_segment_filter"`; editing a **live** (enabled + published) segment additionally needs `confirmLiveChange: true` since it changes targeting immediately. Writes an audit log. | | `get_stripe_settings` / `update_stripe_settings` | Read (with descriptions + recommendations) and change workspace billing settings: proration, cancellation timing, pause behavior, invoice handling on pause, coupon stacking, session recording. Writes require `confirm: "update_stripe_settings"`, validate conflicting combinations with explanations, and are audit-logged with before/after values. *Direct revenue impact.* | | `get_adaptive_offers` / `update_adaptive_offers` | Read and configure adaptive (auto-optimized) discounts: strategy, percent/duration ranges, Intelligence access state. Changes reset the optimizer's learning period; writes require `confirm: "update_adaptive_offers"`. Attaching to a flow step (via `update_blueprint_offer` `config.autoOptimize`) requires both the adaptive-offers and blueprints write scopes. | | `list_recovery_blueprints` / `get_recovery_blueprint` / `clone_recovery_blueprint` / `update_recovery_email` / `publish_recovery_blueprint` | Payment recovery campaign config CRUD: list/read full email sequences (cadence, sender, copy), clone as the template-library path, patch single emails (CTA + merge-tag validation, dashboard length limits, deliverability warnings on sender changes), publish (rebuilds pending sends on in-flight sequences; `confirm` required). Scopes `payment_recovery.blueprints.read/.write`. | @@ -39,7 +43,7 @@ Session and recovery tools read from the Churnkey analytics warehouse — sessio Blueprint edits are **granular and draft-only**: each tool mutates the unlocked working copy and sends only the targeted fields (never the full `steps` array with translations). `update_blueprint_step` handles copy + behavioral flags, `update_blueprint_offer` handles offer config, `edit_survey_structure` handles survey choices/follow-ups, and `add_blueprint_step`/`remove_blueprint_step` handle step structure. Copy edits clear stale translations for the affected content; `publish_blueprint` refreshes translations before making the draft live and is the only live-impacting blueprint action (so it's the one that requires a `confirm`). -Segment tools (`reorder_segments`, `set_segment_enabled`, `update_segment_filter`) act on **live** config directly (segments have no draft cycle), so each requires an explicit `confirm` literal and writes an audit log. +Segment tools (`reorder_segments`, `set_segment_enabled`, `update_segment_filter`, `archive_segment`) act on **live** config directly (segments have no draft cycle), so each requires an explicit `confirm` literal and writes an audit log. Editing the audience of a live (enabled + published) segment additionally requires `confirmLiveChange: true` so the immediate targeting impact is acknowledged. `create_segment_flow` creates a segment plus a fresh draft blueprint (which does follow the draft/`publish_blueprint` cycle), and `list_segment_attributes` enumerates the targetable audience attributes and their valid operands. Note only `INCLUDES`/`NOT_INCLUDES` (STRING/BOOLEAN) and `GTE`/`LTE`/`BETWEEN`/`NOT_BETWEEN` (NUMBER/DATE) are supported — `GT`/`LT` are not. Each tool's input schema is fully described to the MCP client — enums for `saveType` / `offerType` / `billingInterval` / breakdown dimensions, `not` object for exclusions, structured types for booleans and numbers. diff --git a/packages/mcp/src/tools/blueprints.ts b/packages/mcp/src/tools/blueprints.ts index 2e59017..8723877 100644 --- a/packages/mcp/src/tools/blueprints.ts +++ b/packages/mcp/src/tools/blueprints.ts @@ -52,6 +52,22 @@ const updateDraftInput = blueprintIdInput.extend({ updates: draftUpdates, }) +const blueprintTemplate = z + .enum(['empty', 'BASIC', 'B2B', 'MERGEFIELDS']) + .optional() + .describe( + 'Initial draft template. "empty" creates the same blank draft the dashboard creates before a template is picked; BASIC/B2B/MERGEFIELDS prepopulate steps and survey choices.', + ) + +const createBlueprintInput = draftUpdates.extend({ + template: blueprintTemplate, + confirm: z + .literal('create_blueprint') + .describe( + 'Required confirmation. Creating the default org flow changes org configuration; pass exactly "create_blueprint".', + ), +}) + const surveyChoicePatch = z .object({ choiceGuid: z.string().min(1).optional().describe('Preferred stable survey choice identifier.'), @@ -281,6 +297,20 @@ export function blueprintTools(client: ChurnkeyClient): ToolDefinition[] { annotations: { readOnlyHint: true, openWorldHint: true }, handler: async (args) => client.get(`/data/blueprints/${args.blueprintId}`), }, + { + name: 'create_blueprint', + title: 'Create the default org cancel flow', + description: [ + 'Create the default org-level cancel flow draft when the org does not already have one. This mirrors the dashboard setup flow: `template: "empty"` creates a blank draft, while BASIC/B2B/MERGEFIELDS prepopulate draft steps and survey choices.', + '', + 'Use create_segment_flow instead when you need an isolated test flow on an archived/disposable segment. If the default org flow already exists, this tool rejects rather than creating an extra org blueprint that the dashboard/Data API inventory would not surface.', + '', + WRITE_NOTE, + ].join('\n'), + inputSchema: createBlueprintInput, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + handler: async (args) => client.post('/data/blueprints', { body: args }), + }, { name: 'update_blueprint_draft', title: 'Update a draft cancel flow blueprint', diff --git a/packages/mcp/src/tools/segments.ts b/packages/mcp/src/tools/segments.ts index 1e90256..a690159 100644 --- a/packages/mcp/src/tools/segments.ts +++ b/packages/mcp/src/tools/segments.ts @@ -28,26 +28,42 @@ const setEnabledInput = z.object({ ), }) -const SEGMENT_OPERANDS = ['INCLUDES', 'GT', 'LT', 'GTE', 'LTE', 'BETWEEN', 'NOT_INCLUDES', 'NOT_BETWEEN'] as const +const SEGMENT_OPERANDS = ['INCLUDES', 'GTE', 'LTE', 'BETWEEN', 'NOT_INCLUDES', 'NOT_BETWEEN'] as const + +// Catalog of the cancel-flow audience attributes, grouped by value type and the operands that apply. +// This is the cancel-flow palette (mirrors the dashboard's getEnabledSegmentAttributes('cancel-flows')). +// The segmentation engine evaluates more attributes, but they belong to other products (dunning/ +// reactivation) and the cancel-flow builder can't render them, so the API rejects out-of-palette +// built-ins. Org-defined custom attributes are still accepted. +const SEGMENT_ATTRIBUTE_CATALOG = [ + 'Targeting attribute. Call list_segment_attributes for the supported list (with the exact allowed `values` for enum attributes) plus your org-specific custom attributes. The built-in attributes available for cancel flow segments are:', + '• Text/ID (operand INCLUDES or NOT_INCLUDES, value is a list): CUSTOMER_EMAIL, PLAN_ID, PRODUCT_ID, CURRENCY, BILLING_INTERVAL (DAY/WEEK/MONTH/YEAR), SUBSCRIPTION_STATUS (ACTIVE/TRIALING/PAST_DUE), SUBSCRIPTION_DISCOUNT (ONCE/REPEATING/FOREVER).', + '• Number (operand GTE/LTE/BETWEEN/NOT_BETWEEN — note GT/LT are NOT supported): PRICE (major currency units, e.g. dollars, not cents), BILLING_INTERVAL_COUNT, SUBSCRIPTION_AGE_MONTHS.', + '• Date as an ISO-8601 string (operand GTE/LTE/BETWEEN/NOT_BETWEEN): SUBSCRIPTION_START_DATE.', + '• Boolean (operand INCLUDES with value [true] or [false]): CANCEL_FLOW_WILL_SHOW_CLICK_TO_CANCEL.', + 'Fixed-enum attributes (BILLING_INTERVAL, SUBSCRIPTION_STATUS, SUBSCRIPTION_DISCOUNT) require their exact dashboard value (e.g. "MONTH", not "month") and the set can be provider-specific — call list_segment_attributes for the exact `values` allowed for this org.', + 'Built-in attributes scoped to other products (e.g. CUSTOMER_HAS_PHONE, INVOICE_AMOUNT_DUE, SURVEY_CHOICE) are rejected for cancel flow segments. You may also pass any custom customer attribute your org has defined (see list_segment_attributes).', +].join('\n') const filterRule = z .object({ - attribute: z - .string() - .min(1) - .describe('Targeting attribute, e.g. PLAN_ID, PRICE, SUBSCRIPTION_AGE_MONTHS. See list_segments for examples.'), + attribute: z.string().min(1).describe(SEGMENT_ATTRIBUTE_CATALOG), operand: z .enum(SEGMENT_OPERANDS) - .describe('Comparison operand. EQUAL/NOT_EQUAL are NOT supported — use INCLUDES/NOT_INCLUDES.'), + .describe( + 'Comparison operand, scoped to the attribute value type: STRING/BOOLEAN use INCLUDES/NOT_INCLUDES; NUMBER/DATE use GTE/LTE/BETWEEN/NOT_BETWEEN. GT/LT and EQUAL/NOT_EQUAL are NOT supported (use GTE/LTE or INCLUDES/NOT_INCLUDES).', + ), value: z .array(z.unknown()) .describe( - 'Comparison value(s). BETWEEN/NOT_BETWEEN need exactly 2; GT/LT/GTE/LTE need 1; INCLUDES/NOT_INCLUDES is a list.', + 'Comparison value(s). BETWEEN/NOT_BETWEEN need exactly 2; GTE/LTE need 1; INCLUDES/NOT_INCLUDES is a list.', ), type: z .enum(['STRING', 'NUMBER', 'BOOLEAN', 'DATE']) .optional() - .describe('Value type. Usually preserve what list_segments returned for this attribute.'), + .describe( + 'Value type for CUSTOM attributes only. Omit for built-in attributes — they derive their type automatically, and the API ignores/strips type on them (sending it makes the dashboard render a duplicate value input).', + ), }) .strict() .refine((rule) => !['BETWEEN', 'NOT_BETWEEN'].includes(rule.operand) || rule.value.length === 2, { @@ -66,6 +82,70 @@ const updateFilterInput = z.object({ .describe( 'Required confirmation. Editing the filter changes which customers the flow targets; pass exactly "update_segment_filter".', ), + confirmLiveChange: z + .boolean() + .optional() + .describe( + 'Extra acknowledgment required only when the target segment is currently live (enabled AND published): editing its audience changes which customers see the flow immediately. The API rejects the edit with an instruction to re-send with confirmLiveChange: true. Omit (or false) for disabled/unpublished segments.', + ), +}) + +const createSegmentFlowInput = z.object({ + segment: z + .object({ + name: z.string().optional().describe('Segment display name. Defaults to "New Segment".'), + enabled: z + .boolean() + .optional() + .describe( + 'Whether the segment starts enabled. Omit (or pass true) — the dashboard always creates segments enabled, so leave this at its default unless the user explicitly wants the flow created paused. Only pass false to intentionally create a disabled/paused segment.', + ), + filter: z + .array(filterRule) + .optional() + .describe( + 'Initial complete audience filter rules. Defaults to [] so the segment matches no specific audience rules until configured.', + ), + }) + .strict() + .optional() + .describe('Segment metadata and audience rules.'), + blueprint: z + .object({ + template: z + .enum(['empty', 'BASIC', 'B2B', 'MERGEFIELDS']) + .optional() + .describe( + 'Initial draft template. Defaults to "empty"; BASIC/B2B/MERGEFIELDS prepopulate cancel-flow steps and survey choices.', + ), + name: z.string().optional().describe('Blueprint display name.'), + brandImage: z + .string() + .optional() + .describe('Brand image URL. The API accepts raster URLs and images.churnkey.co assets.'), + primaryColor: z.string().optional().describe('Primary hex color for the flow, e.g. "#F7B200".'), + translatedLanguages: z + .array(z.string()) + .optional() + .describe('Locale keys already translated for this blueprint. Usually omit.'), + }) + .strict() + .optional() + .describe('Draft blueprint metadata and optional template.'), + confirm: z + .literal('create_segment_flow') + .describe( + 'Required confirmation. Creating a segment flow changes cancel-flow configuration; pass exactly "create_segment_flow".', + ), +}) + +const archiveSegmentInput = z.object({ + segmentId: z.string().describe('Churnkey segment ID. Use list_segments first if you do not know it.'), + confirm: z + .literal('archive_segment') + .describe( + 'Required confirmation. Archiving hides the segment flow from live inventory; pass exactly "archive_segment".', + ), }) export function segmentTools(client: ChurnkeyClient): ToolDefinition[] { @@ -76,26 +156,64 @@ export function segmentTools(client: ChurnkeyClient): ToolDefinition[] { description: [ 'List cancel flow segment metadata for the authenticated org in priority order (the response order IS the priority — first = highest priority; the 0-based `priority` field mirrors it). Includes disabled segments (each has an `enabled` boolean). Each segment also returns its audience `filter` rules ([{ attribute, operand, value }]) so you can reason about which customers a flow targets.', '', - 'A/B test variant segments appear as separate top-level entries here (the dashboard folds them under their parent test), so treat segments cautiously when reordering. Use this before reorder_segments.', + 'A/B test variant segments appear as separate top-level entries here (the dashboard folds them under their parent test). Unfinished A/B test segments cannot be archived, enabled/disabled, or have their audience edited, and reorder_segments must keep test pairs together.', ].join('\n'), inputSchema: z.object({}), annotations: { readOnlyHint: true, openWorldHint: true }, handler: async () => client.get('/data/segments'), }, + { + name: 'list_segment_attributes', + title: 'List segment audience filter attributes', + description: [ + 'List the audience-filter attributes you can target with update_segment_filter (and the segment.filter field of create_segment_flow). Returns two groups:', + '- `builtIn`: the attributes available for cancel flow segments (the cancel-flow palette, mirroring the dashboard), each with its `valueType` (STRING/NUMBER/DATE/BOOLEAN) and the `operands` that apply. Fixed-enum attributes (e.g. BILLING_INTERVAL, SUBSCRIPTION_STATUS, SUBSCRIPTION_DISCOUNT) also include a `values` array of `{ value, label }` — use the exact `value` (provider-specific). Built-in attributes scoped to other products (e.g. CUSTOMER_HAS_PHONE, INVOICE_AMOUNT_DUE) are not included and are rejected by update_segment_filter.', + "- `custom`: your organization's own custom customer attributes (by `attribute` name, with `label`, `valueType`, and `operands`).", + '', + 'Use this before update_segment_filter so you target real attributes with valid operands. STRING/BOOLEAN use INCLUDES/NOT_INCLUDES; NUMBER/DATE use GTE/LTE/BETWEEN/NOT_BETWEEN (GT/LT are NOT supported). Numeric money attributes (PRICE) are compared in major currency units (e.g. dollars, not cents); dates are ISO-8601 strings.', + ].join('\n'), + inputSchema: z.object({}), + annotations: { readOnlyHint: true, openWorldHint: true }, + handler: async () => client.get('/data/segments/attributes'), + }, + { + name: 'create_segment_flow', + title: 'Create a cancel flow segment draft', + description: [ + 'Create a new cancel-flow segment and its editable draft blueprint in one call. Use this for isolated setup/testing flows: pass `blueprint.template` as "empty", "BASIC", "B2B", or "MERGEFIELDS". The new flow is setup-pending until publish_blueprint is called.', + '', + 'Leave segment.enabled at its default (omit it, or pass true): the dashboard always creates segments enabled, so do NOT disable a new segment unless the user explicitly asks for a paused flow. The segment can be created with an empty filter for setup, but publish_blueprint and enabling both require at least one audience filter rule. Publishing makes the blueprint live but does not change the segment’s enabled state, mirroring the dashboard — use set_segment_enabled to toggle targeting. Use update_segment_filter before publishing if the filter is empty, or archive_segment to clean up a disposable test segment.', + ].join('\n'), + inputSchema: createSegmentFlowInput, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + handler: async (args) => client.post('/data/segments', { body: args }), + }, { name: 'reorder_segments', title: 'Reorder cancel flow segments', description: - 'Reorder cancel flow segment priority. This is a live-impacting configuration change, so it requires explicit confirmation and records an audit log.', + 'Reorder cancel flow segment priority. This is a live-impacting configuration change, so it requires explicit confirmation and records an audit log. If an unfinished A/B test is involved, include both test segments together and keep the control immediately followed by its variant, matching the dashboard grouping.', inputSchema: reorderInput, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, handler: async (args) => client.post('/data/segments/reorder', { body: args }), }, + { + name: 'archive_segment', + title: 'Archive a cancel flow segment', + description: + 'Soft-delete/archive a cancel-flow segment so it disappears from the non-deleted segment inventory. Use this to clean up disposable segment flows created for testing. Segments in unfinished A/B tests cannot be archived. Requires explicit confirmation and records an audit log.', + inputSchema: archiveSegmentInput, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }, + handler: async (args) => + client.post(`/data/segments/${args.segmentId}/archive`, { + body: { confirm: args.confirm }, + }), + }, { name: 'set_segment_enabled', title: 'Enable or disable a cancel flow segment', description: - 'Enable or disable a cancel flow segment. Disabling stops its flow from being served to matching customers (the segment becomes inactive); enabling resumes it. This is a live-impacting configuration change, so it requires explicit confirmation and records an audit log. Use list_segments first to get the segment ID and its current enabled state.', + 'Enable or disable a cancel flow segment. Disabling stops its flow from being served to matching customers (the segment becomes inactive); enabling resumes it and requires at least one audience filter rule. Segments in unfinished A/B tests cannot be enabled or disabled through this tool. This is a live-impacting configuration change, so it requires explicit confirmation and records an audit log. Use list_segments first to get the segment ID, current enabled state, and filter.', inputSchema: setEnabledInput, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }, handler: async (args) => @@ -107,15 +225,15 @@ export function segmentTools(client: ChurnkeyClient): ToolDefinition[] { name: 'update_segment_filter', title: 'Update a cancel flow segment audience filter', description: [ - 'Replace the audience filter rules for a cancel flow segment. This sends the COMPLETE new filter array and fully replaces the existing rules (it is not an add/remove patch) — include every rule you want to keep. This changes which customers the segment targets, so it is live-impacting, requires explicit confirmation, and records an audit log.', + 'Replace the audience filter rules for a cancel flow segment. This sends the COMPLETE new filter array and fully replaces the existing rules (it is not an add/remove patch) — include every rule you want to keep. If the segment is live (enabled AND published), editing its audience changes targeting immediately, so the API requires an extra acknowledgment: re-send with confirmLiveChange: true. Segments in unfinished A/B tests cannot have their audience edited.', '', - 'Each rule is { attribute, operand, value, type? }. operand is one of INCLUDES, GT, LT, GTE, LTE, BETWEEN, NOT_INCLUDES, NOT_BETWEEN. For BETWEEN/NOT_BETWEEN, value has exactly two entries [low, high]; for GT/LT/GTE/LTE, one entry; for INCLUDES/NOT_INCLUDES, the list of matching values. Call list_segments first to read the current rules.', + 'Each rule is { attribute, operand, value, type? }. operand is one of INCLUDES, GTE, LTE, BETWEEN, NOT_INCLUDES, NOT_BETWEEN (GT/LT are NOT supported — use GTE/LTE). STRING/BOOLEAN attributes use INCLUDES/NOT_INCLUDES; NUMBER/DATE use GTE/LTE/BETWEEN/NOT_BETWEEN. For BETWEEN/NOT_BETWEEN, value has exactly two entries [low, high]; for GTE/LTE, one entry; for INCLUDES/NOT_INCLUDES, the list of matching values. See the `attribute` field for the catalog of built-in targeting attributes and which operands apply to each (custom customer attributes are also accepted). Call list_segments first to read the current rules.', ].join('\n'), inputSchema: updateFilterInput, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, handler: async (args) => client.post(`/data/segments/${args.segmentId}/filter`, { - body: { filter: args.filter, confirm: args.confirm }, + body: { filter: args.filter, confirm: args.confirm, confirmLiveChange: args.confirmLiveChange }, }), }, ] diff --git a/packages/mcp/tests/tools/blueprints.test.ts b/packages/mcp/tests/tools/blueprints.test.ts index 72c1c33..1aa86d5 100644 --- a/packages/mcp/tests/tools/blueprints.test.ts +++ b/packages/mcp/tests/tools/blueprints.test.ts @@ -36,6 +36,23 @@ describe('blueprintTools', () => { expect(get).toHaveBeenCalledWith('/data/blueprints/bp_123') }) + it('routes create_blueprint with template and confirmation', async () => { + const { tool, post } = findTool('create_blueprint') + const args = { + template: 'BASIC' as const, + name: 'Test default flow', + primaryColor: '#123456', + confirm: 'create_blueprint' as const, + } + + await tool.handler(args) + + expect(post).toHaveBeenCalledWith('/data/blueprints', { body: args }) + expect(() => tool.inputSchema.parse(args)).not.toThrow() + expect(() => tool.inputSchema.parse({ ...args, template: 'UNKNOWN' })).toThrow() + expect(() => tool.inputSchema.parse({ ...args, confirm: 'yes' })).toThrow() + }) + it('routes update_blueprint_draft with only the updates payload in the body', async () => { const { tool, post } = findTool('update_blueprint_draft') const updates = { diff --git a/packages/mcp/tests/tools/segments.test.ts b/packages/mcp/tests/tools/segments.test.ts index cfcf8fe..59d0c25 100644 --- a/packages/mcp/tests/tools/segments.test.ts +++ b/packages/mcp/tests/tools/segments.test.ts @@ -20,6 +20,83 @@ describe('segmentTools', () => { expect(get).toHaveBeenCalledWith('/data/segments') }) + it('routes list_segment_attributes through the Data API attribute catalog endpoint', async () => { + const { tool, get } = findTool('list_segment_attributes') + + await tool.handler({}) + + expect(get).toHaveBeenCalledWith('/data/segments/attributes') + expect(tool.annotations?.readOnlyHint).toBe(true) + expect(tool.description).toContain('builtIn') + expect(tool.description).toContain('custom') + }) + + it('routes create_segment_flow with nested segment and blueprint payload', async () => { + const { tool, post } = findTool('create_segment_flow') + const args = { + segment: { + name: 'MCP test segment', + filter: [{ attribute: 'PLAN_ID', operand: 'INCLUDES' as const, value: ['price_1'] }], + }, + blueprint: { template: 'BASIC' as const, name: 'MCP test flow' }, + confirm: 'create_segment_flow' as const, + } + + await tool.handler(args) + + expect(post).toHaveBeenCalledWith('/data/segments', { body: args }) + expect(() => tool.inputSchema.parse(args)).not.toThrow() + expect(() => tool.inputSchema.parse({ ...args, blueprint: { template: 'UNKNOWN' } })).toThrow() + expect(() => tool.inputSchema.parse({ ...args, confirm: 'yes' })).toThrow() + }) + + it('documents segment audience requirements for publish and enable', () => { + const { tool: createTool } = findTool('create_segment_flow') + const { tool: enableTool } = findTool('set_segment_enabled') + + expect(createTool.description).toContain( + 'publish_blueprint and enabling both require at least one audience filter rule', + ) + // Publishing must NOT promise to auto-enable the segment (mirrors the dashboard). + expect(createTool.description).not.toContain('enables the segment automatically') + expect(createTool.description).toContain('does not change the segment’s enabled state') + expect(enableTool.description).toContain('requires at least one audience filter rule') + }) + + it('documents A/B test and live audience guardrails', () => { + const { tool: listTool } = findTool('list_segments') + const { tool: reorderTool } = findTool('reorder_segments') + const { tool: archiveTool } = findTool('archive_segment') + const { tool: enableTool } = findTool('set_segment_enabled') + const { tool: filterTool } = findTool('update_segment_filter') + + expect(listTool.description).toContain('Unfinished A/B test segments cannot be archived') + expect(reorderTool.description).toContain('keep the control immediately followed by its variant') + expect(archiveTool.description).toContain('Segments in unfinished A/B tests cannot be archived') + expect(enableTool.description).toContain('unfinished A/B tests cannot be enabled or disabled') + expect(filterTool.description).toContain('changes targeting immediately') + expect(filterTool.description).toContain('confirmLiveChange: true') + }) + + it('passes confirmLiveChange through for live segment audience edits', async () => { + const { tool, post } = findTool('update_segment_filter') + const args = { + segmentId: 'seg_1', + filter: [{ attribute: 'SUBSCRIPTION_AGE_MONTHS', operand: 'GTE' as const, value: [3] }], + confirm: 'update_segment_filter' as const, + confirmLiveChange: true, + } + + await tool.handler(args) + + expect(post).toHaveBeenCalledWith('/data/segments/seg_1/filter', { + body: { filter: args.filter, confirm: 'update_segment_filter', confirmLiveChange: true }, + }) + expect(() => tool.inputSchema.parse(args)).not.toThrow() + // confirmLiveChange is optional + expect(() => tool.inputSchema.parse({ ...args, confirmLiveChange: undefined })).not.toThrow() + }) + it('routes reorder_segments with the full confirmed body', async () => { const { tool, post } = findTool('reorder_segments') const args = { segmentIds: ['seg_1', 'seg_2'], confirm: 'reorder_segments' as const } @@ -38,6 +115,16 @@ describe('segmentTools', () => { expect(() => tool.inputSchema.parse({ segmentIds: ['seg_1'], confirm: 'yes' })).toThrow() }) + it('routes archive_segment with explicit confirmation', async () => { + const { tool, post } = findTool('archive_segment') + + await tool.handler({ segmentId: 'seg_1', confirm: 'archive_segment' }) + + expect(post).toHaveBeenCalledWith('/data/segments/seg_1/archive', { body: { confirm: 'archive_segment' } }) + expect(() => tool.inputSchema.parse({ segmentId: 'seg_1', confirm: 'archive_segment' })).not.toThrow() + expect(() => tool.inputSchema.parse({ segmentId: 'seg_1', confirm: 'yes' })).toThrow() + }) + it('routes set_segment_enabled with the confirmed body', async () => { const { tool, post } = findTool('set_segment_enabled')