diff --git a/PRD.md b/PRD.md index d2faacf..4874c3a 100644 --- a/PRD.md +++ b/PRD.md @@ -387,6 +387,57 @@ Returns a comprehensive markdown document containing: --- +## Phase 4: Strategy Constraints (Planned) + +### Objective + +Add `constraints` parameter to `set_flag_rollout` tool for targeting based on context fields. + +### Scope + +Extend `set_flag_rollout` to accept an optional `constraints` array passed through to the Unleash Admin API. + +**Supported Context Fields** (Built-in): +- `userId`, `sessionId`, `remoteAddress`, `environment`, `appName`, `currentTime`, `properties` + +**Note**: Custom context fields (e.g., `country`, `appVersion`) are supported but require Unleash Admin configuration. + +### Input Schema + +```typescript +interface StrategyConstraint { + contextName: string; // e.g., "userId", "appName" + operator: ConstraintOperator; // e.g., "IN", "NOT_IN", "SEMVER_GTE" + values?: string[]; // For IN/NOT_IN + value?: string; // For single value operators +} +``` + +**Operators**: `IN`, `NOT_IN`, `STR_CONTAINS`, `STR_STARTS_WITH`, `STR_ENDS_WITH`, `NUM_EQ/GT/GTE/LT/LTE`, `SEMVER_EQ/GT/GTE/LT/LTE` + +### Example + +```json +{ + "featureName": "new-feature", + "environment": "production", + "rolloutPercentage": 100, + "constraints": [ + { "contextName": "userId", "operator": "IN", "values": ["user123", "user456"] } + ] +} +``` + +### TODO: Phase 4 Tasks + +- [ ] Add `StrategyConstraint` type to `src/unleash/client.ts` +- [ ] Update `setFlexibleRolloutStrategy` to include constraints in payload +- [ ] Add constraint schema to `src/tools/setFlagRollout.ts` +- [ ] Update README with examples +- [ ] Test against live Unleash instance + +--- + ## Cross-Phase Tasks ### Documentation @@ -451,6 +502,10 @@ Returns a comprehensive markdown document containing: - [x] Users can copy-paste snippets directly into their codebase - [x] Framework-specific templates for major frameworks +### Phase 4 (Planned) +- [ ] Constraints parameter working in `set_flag_rollout` +- [ ] Constraints correctly applied in Unleash dashboard + ### Overall - [x] LLM assistants can complete full flag workflow without human intervention - [x] Average time to create and wrap a flag: <2 minutes @@ -488,7 +543,6 @@ Returns a comprehensive markdown document containing: - Tag assignment during creation - Initial strategy configuration - Variant creation - - Constraint configuration --- diff --git a/README.md b/README.md index 5f7301c..8a27777 100644 --- a/README.md +++ b/README.md @@ -565,6 +565,64 @@ Returns a comprehensive, markdown-formatted string that guides the user on how t [If-block, guard clause, hooks, ternary, etc.] ``` +### Set flag rollout + +The `set_flag_rollout` tool configures a `flexibleRollout` strategy for a feature flag in a specific environment. This includes rollout percentage, stickiness, variants, and constraints for targeting. + +#### When to use + +Use this tool after creating a feature flag when you want to configure how the flag rolls out to users. This tool does **not** enable the flag; use `toggle_flag_environment` to turn it on. + +#### Parameters + +- `featureName` (required): The feature flag name. +- `environment` (required): Target environment (e.g., `development`, `production`). +- `rolloutPercentage` (required): Percentage of users to receive the feature (0-100). +- `projectId` (optional): Project ID (defaults to `UNLEASH_DEFAULT_PROJECT`). +- `groupId` (optional): Group ID for stickiness bucketing (defaults to feature name). +- `stickiness` (optional): Stickiness field (defaults to `"default"`). +- `title` (optional): Descriptive title for the strategy. +- `disabled` (optional): Disable the strategy (defaults to `false`). +- `variants` (optional): List of strategy-level variants. +- `constraints` (optional): List of targeting constraints. + +#### Constraints + +Constraints allow you to target specific users based on context fields. Each constraint has: + +- `contextName` (required): Context field name (e.g., `userId`, `environment`). +- `operator` (required): Comparison operator. +- `values` (optional): Values for multi-value operators (`IN`, `NOT_IN`). +- `value` (optional): Single value for single-value operators. +- `caseInsensitive` (optional): Case insensitive matching. +- `inverted` (optional): Invert the constraint result. + +**Available operators:** +- Basic: `IN`, `NOT_IN` +- String: `STR_CONTAINS`, `STR_STARTS_WITH`, `STR_ENDS_WITH` +- Numeric: `NUM_EQ`, `NUM_GT`, `NUM_GTE`, `NUM_LT`, `NUM_LTE` +- Date: `DATE_AFTER`, `DATE_BEFORE` +- Semver: `SEMVER_EQ`, `SEMVER_GT`, `SEMVER_LT` + +#### Usage example + +**Tool payload** + +```json +{ + "featureName": "new-checkout-flow", + "environment": "production", + "rolloutPercentage": 50, + "constraints": [ + { + "contextName": "userId", + "operator": "IN", + "values": ["user-123", "user-456"] + } + ] +} +``` + ## Architecture The server follows a focused, purpose-driven design. diff --git a/src/tools/setFlagRollout.ts b/src/tools/setFlagRollout.ts index 339c5f8..4052938 100644 --- a/src/tools/setFlagRollout.ts +++ b/src/tools/setFlagRollout.ts @@ -6,7 +6,11 @@ import { resolveProjectId, type ServerContext, } from '../context.js'; -import type { StrategyVariant, StrategyVariantPayload } from '../unleash/client.js'; +import type { + StrategyConstraint, + StrategyVariant, + StrategyVariantPayload, +} from '../unleash/client.js'; import { createFlagResourceLink } from '../utils/streaming.js'; const variantPayloadSchema = z.object({ @@ -28,6 +32,35 @@ const variantSchema = z }) .describe('Strategy-level variant definition'); +const constraintOperatorSchema = z.enum([ + 'IN', + 'NOT_IN', + 'STR_CONTAINS', + 'STR_STARTS_WITH', + 'STR_ENDS_WITH', + 'NUM_EQ', + 'NUM_GT', + 'NUM_GTE', + 'NUM_LT', + 'NUM_LTE', + 'DATE_AFTER', + 'DATE_BEFORE', + 'SEMVER_EQ', + 'SEMVER_GT', + 'SEMVER_LT', +]); + +const constraintSchema = z + .object({ + contextName: z.string().min(1).describe('Context field name (e.g., userId, environment)'), + operator: constraintOperatorSchema.describe('Comparison operator'), + values: z.array(z.string()).optional().describe('Values for multi-value operators (IN, NOT_IN)'), + value: z.string().optional().describe('Single value for single-value operators'), + caseInsensitive: z.boolean().optional().describe('Case insensitive matching (default: false)'), + inverted: z.boolean().optional().describe('Invert the constraint result (default: false)'), + }) + .describe('Strategy constraint for targeting'); + const setFlagRolloutSchema = z.object({ projectId: z .string() @@ -46,6 +79,10 @@ const setFlagRolloutSchema = z.object({ title: z.string().optional().describe('Optional descriptive title for the strategy'), disabled: z.boolean().optional().describe('Disable the strategy (defaults to false)'), variants: z.array(variantSchema).optional().describe('Optional list of strategy-level variants'), + constraints: z + .array(constraintSchema) + .optional() + .describe('Optional list of strategy constraints for targeting'), }); type SetFlagRolloutInput = z.infer; @@ -79,6 +116,17 @@ export async function setFlagRollout( ...(variant.payload ? { payload: variant.payload } : {}), })); + const constraints: StrategyConstraint[] | undefined = input.constraints?.map((constraint) => ({ + contextName: constraint.contextName, + operator: constraint.operator, + ...(constraint.values ? { values: constraint.values } : {}), + ...(constraint.value ? { value: constraint.value } : {}), + ...(constraint.caseInsensitive !== undefined + ? { caseInsensitive: constraint.caseInsensitive } + : {}), + ...(constraint.inverted !== undefined ? { inverted: constraint.inverted } : {}), + })); + const strategy = await context.unleashClient.setFlexibleRolloutStrategy( projectId, input.featureName, @@ -90,6 +138,7 @@ export async function setFlagRollout( title: input.title, disabled: input.disabled, variants, + constraints, }, ); diff --git a/src/unleash/client.ts b/src/unleash/client.ts index b51a26c..d5ab17f 100644 --- a/src/unleash/client.ts +++ b/src/unleash/client.ts @@ -69,6 +69,40 @@ export interface StrategyVariant { [key: string]: unknown; } +/** + * Constraint operators supported by Unleash. + * See: https://docs.getunleash.io/reference/activation-strategies + */ +export type ConstraintOperator = + | 'IN' + | 'NOT_IN' + | 'STR_CONTAINS' + | 'STR_STARTS_WITH' + | 'STR_ENDS_WITH' + | 'NUM_EQ' + | 'NUM_GT' + | 'NUM_GTE' + | 'NUM_LT' + | 'NUM_LTE' + | 'DATE_AFTER' + | 'DATE_BEFORE' + | 'SEMVER_EQ' + | 'SEMVER_GT' + | 'SEMVER_LT'; + +/** + * Strategy constraint for targeting. + * See: https://docs.getunleash.io/reference/activation-strategies + */ +export interface StrategyConstraint { + contextName: string; + operator: ConstraintOperator; + values?: string[]; + value?: string; + caseInsensitive?: boolean; + inverted?: boolean; +} + export interface SetFlagRolloutOptions { rolloutPercentage: number; groupId?: string; @@ -76,6 +110,7 @@ export interface SetFlagRolloutOptions { title?: string; disabled?: boolean; variants?: StrategyVariant[]; + constraints?: StrategyConstraint[]; } export interface FeatureStrategy { @@ -86,7 +121,7 @@ export interface FeatureStrategy { featureName?: string; sortOrder?: number; segments?: number[]; - constraints?: Array>; + constraints?: StrategyConstraint[]; variants?: StrategyVariant[]; parameters: Record; } @@ -265,6 +300,9 @@ export class UnleashClient { disabled: options.disabled, parameters, ...(options.variants && options.variants.length > 0 ? { variants: options.variants } : {}), + ...(options.constraints && options.constraints.length > 0 + ? { constraints: options.constraints } + : {}), }; if (this.dryRun) { @@ -276,6 +314,7 @@ export class UnleashClient { featureName, parameters, variants: payload.variants ?? [], + constraints: payload.constraints ?? [], }; }