diff --git a/products/feature_flags/mcp/tools.yaml b/products/feature_flags/mcp/tools.yaml index 0813942c413c..c08f9bc475b9 100644 --- a/products/feature_flags/mcp/tools.yaml +++ b/products/feature_flags/mcp/tools.yaml @@ -94,13 +94,148 @@ tools: enabled: false feature-flag-archive: operation: feature_flags_archive_create - enabled: false + enabled: true + scopes: + - feature_flag:write + annotations: + readOnly: false + destructive: false + idempotent: true + enrich_url: '{id}' + title: Archive feature flag + description: > + Archive a feature flag, hiding it from the default flag list. Requires the flag's numeric ID; use + `feature-flag-get-definition-by-key` to get it from a key, or `feature-flag-get-all` to search by key or + name. + + + Sets `archived` to true. An archived flag must be disabled, so an enabled flag is turned off in the same + call. Nothing else changes: this tool never accepts or replaces the `filters` object, and linked experiment + and survey history is preserved. There is no request body. + + + When the flag is enabled, archiving stops it evaluating everywhere. Tell the user what else this affects: + linked experiments, surveys, early access features and session replay settings all appear in the flag's full + definition. + + + Returns 400 when the flag is enabled and other active flags depend on it. Returns 409 when an approval + policy gates disabling. Archiving an already-archived flag succeeds and changes nothing, so retrying is + safe. + + + A key lookup that finds nothing does not mean the flag is absent. Archived flags are hidden from + `feature-flag-get-definition-by-key` and from `feature-flag-get-all`, so a flag that is already archived + looks missing on a retry. Pass `{"archived":"true"}` to `feature-flag-get-all` before you report the flag as + missing. + + + Use `feature-flag-unarchive` to put the flag back in the list, or `delete-feature-flag` when the user wants + it gone rather than tidied away. + param_overrides: + id: + cast: string-int + response: + include: + - id + - key + - active + - archived + - status + - version feature-flag-disable: operation: feature_flags_disable_create - enabled: false + enabled: true + scopes: + - feature_flag:write + annotations: + readOnly: false + destructive: false + idempotent: true + enrich_url: '{id}' + title: Disable feature flag + description: > + Turn a feature flag off. Requires the flag's numeric ID; use `feature-flag-get-definition-by-key` to get it + from a key, or `feature-flag-get-all` to search by key or name. + + + Sets `active` to false and changes nothing else. This tool never accepts or replaces the `filters` object, + so targeting, rollout percentages, variants and payloads cannot change. There is no request body. + + + A disabled flag stops evaluating everywhere, so every caller falls back to its own default. Tell the user + what else this affects: linked experiments, surveys, early access features and session replay settings all + appear in the flag's full definition. `feature-flag-get-definition-by-key` already returns it, and + `feature-flag-get-definition` returns it by ID. + + + Returns 400 when other active flags depend on this one; `feature-flags-dependent-flags-retrieve` lists them. + Returns 409 when an approval policy gates disabling. Disabling an already-disabled flag succeeds and changes + nothing, so retrying is safe. + + + A key lookup that finds nothing does not mean the flag is absent. Archived flags are hidden from + `feature-flag-get-definition-by-key` and from `feature-flag-get-all`, and an archived flag is already + disabled. Pass `{"archived":"true"}` to `feature-flag-get-all` before you report the flag as missing. + + + To turn the flag off at a future time instead, use `scheduled-changes-create` with the `update_status` + operation. + param_overrides: + id: + cast: string-int + response: + include: + - id + - key + - active + - archived + - status + - version feature-flag-enable: operation: feature_flags_enable_create - enabled: false + enabled: true + scopes: + - feature_flag:write + annotations: + readOnly: false + destructive: false + idempotent: true + enrich_url: '{id}' + title: Enable feature flag + description: > + Turn a feature flag on. Requires the flag's numeric ID; use `feature-flag-get-definition-by-key` to get it + from a key, or `feature-flag-get-all` to search by key or name. + + + Sets `active` to true and changes nothing else. This tool never accepts or replaces the `filters` object, so + targeting, rollout percentages, variants and payloads cannot change. There is no request body. To change who + the flag is served to, use `update-feature-flag`. + + + Returns 400 when the flag is archived (call `feature-flag-unarchive` first) or when a flag it depends on is + disabled. Returns 409 when an approval policy gates enabling. Enabling an already-enabled flag succeeds and + changes nothing, so retrying is safe. + + + A key lookup that finds nothing does not mean the flag is absent. Archived flags are hidden from + `feature-flag-get-definition-by-key` and from `feature-flag-get-all`. Pass `{"archived":"true"}` to + `feature-flag-get-all` before you report the flag as missing. + + + To turn the flag on at a future time instead, use `scheduled-changes-create` with the `update_status` + operation. + param_overrides: + id: + cast: string-int + response: + include: + - id + - key + - active + - archived + - status + - version feature-flag-get-all: operation: feature_flags_list enabled: true @@ -153,7 +288,37 @@ tools: cast: string-int feature-flag-unarchive: operation: feature_flags_unarchive_create - enabled: false + enabled: true + scopes: + - feature_flag:write + annotations: + readOnly: false + destructive: false + idempotent: true + enrich_url: '{id}' + title: Unarchive feature flag + description: > + Restore an archived feature flag to the default flag list. Requires the flag's numeric ID; pass + `{"archived":"true"}` to `feature-flag-get-all` to find archived flags. + + + Sets `archived` to false and changes nothing else. This tool never accepts or replaces the `filters` object. + There is no request body. + + + The flag stays turned off. Call `feature-flag-enable` when the user also wants it serving again. Unarchiving + a flag that is not archived succeeds and changes nothing, so retrying is safe. + param_overrides: + id: + cast: string-int + response: + include: + - id + - key + - active + - archived + - status + - version feature-flags-activity-retrieve: operation: feature_flags_activity_retrieve enabled: true @@ -499,12 +664,23 @@ tools: enrich_url: '{id}' title: Update feature flag description: > - Update a feature flag by numeric ID (partial update — only the fields you include are changed). Call when - the user asks to enable or disable a flag, change its rollout percentage or release conditions, edit - variants or payloads, or update its description. Rollout conditions, variants, and payloads live in the - structured `filters` param, and sending `filters` replaces the whole object — read the flag first with - `feature-flag-get-definition` and merge your change. To apply a change at a future time instead, use - `scheduled-changes-create`. Use `feature-flag-get-all` to find the numeric ID from a key. + Update a feature flag by numeric ID (partial update: only the fields you include are changed). Use this for + edits that have no dedicated tool, such as release conditions, rollout percentages, variants, payloads, tags + and the description. + + + Prefer a dedicated tool whenever the request matches one. `feature-flag-enable`, `feature-flag-disable`, + `feature-flag-archive` and `feature-flag-unarchive` each change one state field and send no `filters`. A + targeting change someone else made between your read and your write is therefore not overwritten. Send + `active` or `archived` here only when the same request also changes targeting or another field, so the whole + edit lands in one write. + + + Rollout conditions, variants, and payloads live in the structured `filters` param, and sending `filters` + replaces the whole object, so read the flag first and merge your change. Use + `feature-flag-get-definition-by-key` when you have the string key: it returns the numeric ID and the full + definition in one call. Use `feature-flag-get-definition` when you already have the ID. To apply a change at + a future time instead, use `scheduled-changes-create`. include_params: - key - name diff --git a/products/feature_flags/skills/cleaning-up-stale-feature-flags/SKILL.md b/products/feature_flags/skills/cleaning-up-stale-feature-flags/SKILL.md index 7947c716c4eb..521d95363ab5 100644 --- a/products/feature_flags/skills/cleaning-up-stale-feature-flags/SKILL.md +++ b/products/feature_flags/skills/cleaning-up-stale-feature-flags/SKILL.md @@ -113,7 +113,7 @@ Present the full cleanup prompt in a copyable format so the user can paste it di Present the user with both options and their tradeoffs: -- **Disable** (`active: false`) via `posthog:update-feature-flag`: The flag stops being evaluated but the configuration is preserved. If something was missed in the code cleanup, re-enabling is instant. Recommended as the default. +- **Disable** via `posthog:feature-flag-disable`: The flag stops being evaluated but the configuration is preserved. The tool takes only the flag id, so targeting cannot change. If something was missed in the code cleanup, re-enabling is instant. Recommended as the default. - **Delete** via `posthog:delete-feature-flag`: A soft-delete — the flag is marked as deleted but not physically removed. Keeps the flag list clean, but re-enabling requires recreating the flag. Better for flags the user is confident they'll never need again. Once the user has chosen and confirms their code changes are deployed, apply the chosen action one flag at a time. Confirm each action so it's easy to stop if something goes wrong. @@ -171,7 +171,7 @@ Agent steps: - Delete: removes from the list, but you'd need to recreate if needed" - User confirms: "Disable them, code is deployed" -- Disable each flag using posthog:update-feature-flag (active: false) +- Disable each flag using posthog:feature-flag-disable - Confirm: "Both flags are now disabled in PostHog." ``` @@ -190,5 +190,5 @@ Agent steps: - `posthog:feature-flag-get-all`: List and search feature flags (supports `active: "STALE"` filter) - `posthog:feature-flag-get-definition`: Get full flag details including experiment associations - `posthog:feature-flags-status-retrieve`: Get the status and reason for a single flag -- `posthog:update-feature-flag`: Disable a flag by setting `active: false` +- `posthog:feature-flag-disable`: Turn a flag off without touching its targeting - `posthog:delete-feature-flag`: Soft-delete a flag diff --git a/services/mcp/evals/benchmark/tasks.yaml b/services/mcp/evals/benchmark/tasks.yaml index dbcac1446f52..dc293852cb92 100644 --- a/services/mcp/evals/benchmark/tasks.yaml +++ b/services/mcp/evals/benchmark/tasks.yaml @@ -212,6 +212,67 @@ tasks: acceptable_tools: [feature-flag-get-all, feature-flag-get-definition] success_criteria: "Resolves the flag by its key ('mcp-analytics') directly — does not guess a numeric id." + # The lifecycle tasks below all act on one flag key, so an agent-mode run flips only that + # flag's state. They score tool selection whether or not `mcp-eval-lifecycle` exists, + # because a missing flag must produce a clear answer rather than a fallback to the generic + # update tool. Nothing seeds the key today; seed it to also exercise the state change. + # One shared key makes the run order-dependent. `flag-unarchive-stays-off` needs the flag + # archived, and only `flag-archive-completed` leaves it that way. A second full run also + # starts from the targeting the first one wrote. Give each task its own key before relying + # on the state-change half of these scores. + - id: flag-enable-by-key + category: feature-flags + intent: 'Turn on the mcp-eval-lifecycle feature flag.' + expected_tools: [feature-flag-enable] + acceptable_tools: [feature-flag-get-definition-by-key, feature-flag-get-all, feature-flag-get-definition] + success_criteria: > + Calls feature-flag-enable rather than a generic update, and sends no filters payload in any call. If the + flag does not exist, says so rather than reaching for update-feature-flag. + + - id: flag-disable-reports-impact + category: feature-flags + intent: "We're seeing errors from the mcp-eval-lifecycle flag. Switch it off." + expected_tools: [feature-flag-disable] + acceptable_tools: + [ + feature-flag-get-definition-by-key, + feature-flag-get-all, + feature-flag-get-definition, + feature-flags-dependent-flags-retrieve, + ] + success_criteria: > + Resolves the key to an id and calls feature-flag-disable with no filters payload. Reads the definition first + and says what else the flag is wired into (linked experiments, surveys, replay settings), or that nothing + is. If the flag does not exist, says so rather than reaching for update-feature-flag. + + - id: flag-archive-completed + category: feature-flags + intent: 'The mcp-eval-lifecycle rollout is finished. Archive the flag so it stops cluttering the list.' + expected_tools: [feature-flag-archive] + acceptable_tools: [feature-flag-get-definition-by-key, feature-flag-get-all, feature-flag-get-definition] + success_criteria: > + Calls feature-flag-archive with no filters payload, and tells the user archiving also turns the flag off. + If the flag does not exist, says so rather than reaching for update-feature-flag. + + - id: flag-unarchive-stays-off + category: feature-flags + intent: 'Bring the mcp-eval-lifecycle flag back into the flag list, I need to look at it again.' + expected_tools: [feature-flag-unarchive] + acceptable_tools: [feature-flag-get-all, feature-flag-get-definition-by-key, feature-flag-get-definition] + success_criteria: > + Calls feature-flag-unarchive with no filters payload and does not enable it, saying the flag is still turned + off. Finds the archived flag by passing {"archived":"true"} to feature-flag-get-all; a plain key lookup returning + nothing is not grounds for reporting that the flag does not exist. + + - id: flag-targeting-edit-uses-update + category: feature-flags + intent: 'Change the mcp-eval-lifecycle flag so it only applies to users on the @example.com domain.' + expected_tools: [update-feature-flag] + acceptable_tools: [feature-flag-get-definition-by-key, feature-flag-get-definition, feature-flag-get-all] + success_criteria: > + Reads the current definition first, then edits targeting through update-feature-flag. Does not reach for a + lifecycle tool, which cannot change targeting. If the flag does not exist, says so. + # --- session-replay ---------------------------------------------------------- - id: replay-rage-clicks category: session-replay diff --git a/services/mcp/schema/generated-tool-definitions.json b/services/mcp/schema/generated-tool-definitions.json index 7833bfb1f146..55805302c1eb 100644 --- a/services/mcp/schema/generated-tool-definitions.json +++ b/services/mcp/schema/generated-tool-definitions.json @@ -4821,6 +4821,48 @@ "readOnlyHint": true } }, + "feature-flag-archive": { + "description": "Archive a feature flag, hiding it from the default flag list. Requires the flag's numeric ID; use `feature-flag-get-definition-by-key` to get it from a key, or `feature-flag-get-all` to search by key or name.\n\nSets `archived` to true. An archived flag must be disabled, so an enabled flag is turned off in the same call. Nothing else changes: this tool never accepts or replaces the `filters` object, and linked experiment and survey history is preserved. There is no request body.\n\nWhen the flag is enabled, archiving stops it evaluating everywhere. Tell the user what else this affects: linked experiments, surveys, early access features and session replay settings all appear in the flag's full definition.\n\nReturns 400 when the flag is enabled and other active flags depend on it. Returns 409 when an approval policy gates disabling. Archiving an already-archived flag succeeds and changes nothing, so retrying is safe.\n\nA key lookup that finds nothing does not mean the flag is absent. Archived flags are hidden from `feature-flag-get-definition-by-key` and from `feature-flag-get-all`, so a flag that is already archived looks missing on a retry. Pass `{\"archived\":\"true\"}` to `feature-flag-get-all` before you report the flag as missing.\n\nUse `feature-flag-unarchive` to put the flag back in the list, or `delete-feature-flag` when the user wants it gone rather than tidied away.", + "category": "Feature flags", + "feature": "flags", + "summary": "Archive feature flag", + "title": "Archive feature flag", + "required_scopes": ["feature_flag:write"], + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "readOnlyHint": false + } + }, + "feature-flag-disable": { + "description": "Turn a feature flag off. Requires the flag's numeric ID; use `feature-flag-get-definition-by-key` to get it from a key, or `feature-flag-get-all` to search by key or name.\n\nSets `active` to false and changes nothing else. This tool never accepts or replaces the `filters` object, so targeting, rollout percentages, variants and payloads cannot change. There is no request body.\n\nA disabled flag stops evaluating everywhere, so every caller falls back to its own default. Tell the user what else this affects: linked experiments, surveys, early access features and session replay settings all appear in the flag's full definition. `feature-flag-get-definition-by-key` already returns it, and `feature-flag-get-definition` returns it by ID.\n\nReturns 400 when other active flags depend on this one; `feature-flags-dependent-flags-retrieve` lists them. Returns 409 when an approval policy gates disabling. Disabling an already-disabled flag succeeds and changes nothing, so retrying is safe.\n\nA key lookup that finds nothing does not mean the flag is absent. Archived flags are hidden from `feature-flag-get-definition-by-key` and from `feature-flag-get-all`, and an archived flag is already disabled. Pass `{\"archived\":\"true\"}` to `feature-flag-get-all` before you report the flag as missing.\n\nTo turn the flag off at a future time instead, use `scheduled-changes-create` with the `update_status` operation.", + "category": "Feature flags", + "feature": "flags", + "summary": "Disable feature flag", + "title": "Disable feature flag", + "required_scopes": ["feature_flag:write"], + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "readOnlyHint": false + } + }, + "feature-flag-enable": { + "description": "Turn a feature flag on. Requires the flag's numeric ID; use `feature-flag-get-definition-by-key` to get it from a key, or `feature-flag-get-all` to search by key or name.\n\nSets `active` to true and changes nothing else. This tool never accepts or replaces the `filters` object, so targeting, rollout percentages, variants and payloads cannot change. There is no request body. To change who the flag is served to, use `update-feature-flag`.\n\nReturns 400 when the flag is archived (call `feature-flag-unarchive` first) or when a flag it depends on is disabled. Returns 409 when an approval policy gates enabling. Enabling an already-enabled flag succeeds and changes nothing, so retrying is safe.\n\nA key lookup that finds nothing does not mean the flag is absent. Archived flags are hidden from `feature-flag-get-definition-by-key` and from `feature-flag-get-all`. Pass `{\"archived\":\"true\"}` to `feature-flag-get-all` before you report the flag as missing.\n\nTo turn the flag on at a future time instead, use `scheduled-changes-create` with the `update_status` operation.", + "category": "Feature flags", + "feature": "flags", + "summary": "Enable feature flag", + "title": "Enable feature flag", + "required_scopes": ["feature_flag:write"], + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "readOnlyHint": false + } + }, "feature-flag-get-all": { "description": "Get feature flags in the current project. Supports list filters including search by feature flag key or name (case-insensitive), then use the returned ID for get/update/delete tools.", "category": "Feature flags", @@ -4849,6 +4891,20 @@ "readOnlyHint": true } }, + "feature-flag-unarchive": { + "description": "Restore an archived feature flag to the default flag list. Requires the flag's numeric ID; pass `{\"archived\":\"true\"}` to `feature-flag-get-all` to find archived flags.\n\nSets `archived` to false and changes nothing else. This tool never accepts or replaces the `filters` object. There is no request body.\n\nThe flag stays turned off. Call `feature-flag-enable` when the user also wants it serving again. Unarchiving a flag that is not archived succeeds and changes nothing, so retrying is safe.", + "category": "Feature flags", + "feature": "flags", + "summary": "Unarchive feature flag", + "title": "Unarchive feature flag", + "required_scopes": ["feature_flag:write"], + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "readOnlyHint": false + } + }, "feature-flags-activity-retrieve": { "description": "Get the audit trail for a specific feature flag by ID. Returns a paginated list of changes including who made changes, what was changed, and when. Use limit and page query params for pagination.", "category": "Feature flags", @@ -10782,7 +10838,7 @@ "feature_flag": "tasks" }, "update-feature-flag": { - "description": "Update a feature flag by numeric ID (partial update — only the fields you include are changed). Call when the user asks to enable or disable a flag, change its rollout percentage or release conditions, edit variants or payloads, or update its description. Rollout conditions, variants, and payloads live in the structured `filters` param, and sending `filters` replaces the whole object — read the flag first with `feature-flag-get-definition` and merge your change. To apply a change at a future time instead, use `scheduled-changes-create`. Use `feature-flag-get-all` to find the numeric ID from a key.", + "description": "Update a feature flag by numeric ID (partial update: only the fields you include are changed). Use this for edits that have no dedicated tool, such as release conditions, rollout percentages, variants, payloads, tags and the description.\n\nPrefer a dedicated tool whenever the request matches one. `feature-flag-enable`, `feature-flag-disable`, `feature-flag-archive` and `feature-flag-unarchive` each change one state field and send no `filters`. A targeting change someone else made between your read and your write is therefore not overwritten. Send `active` or `archived` here only when the same request also changes targeting or another field, so the whole edit lands in one write.\n\nRollout conditions, variants, and payloads live in the structured `filters` param, and sending `filters` replaces the whole object, so read the flag first and merge your change. Use `feature-flag-get-definition-by-key` when you have the string key: it returns the numeric ID and the full definition in one call. Use `feature-flag-get-definition` when you already have the ID. To apply a change at a future time instead, use `scheduled-changes-create`.", "category": "Feature flags", "feature": "flags", "summary": "Update feature flag", diff --git a/services/mcp/schema/tool-definitions-all.json b/services/mcp/schema/tool-definitions-all.json index d68ad53851b3..8c1f6b1346a4 100644 --- a/services/mcp/schema/tool-definitions-all.json +++ b/services/mcp/schema/tool-definitions-all.json @@ -4976,6 +4976,48 @@ "readOnlyHint": true } }, + "feature-flag-archive": { + "description": "Archive a feature flag, hiding it from the default flag list. Requires the flag's numeric ID; use `feature-flag-get-definition-by-key` to get it from a key, or `feature-flag-get-all` to search by key or name.\n\nSets `archived` to true. An archived flag must be disabled, so an enabled flag is turned off in the same call. Nothing else changes: this tool never accepts or replaces the `filters` object, and linked experiment and survey history is preserved. There is no request body.\n\nWhen the flag is enabled, archiving stops it evaluating everywhere. Tell the user what else this affects: linked experiments, surveys, early access features and session replay settings all appear in the flag's full definition.\n\nReturns 400 when the flag is enabled and other active flags depend on it. Returns 409 when an approval policy gates disabling. Archiving an already-archived flag succeeds and changes nothing, so retrying is safe.\n\nA key lookup that finds nothing does not mean the flag is absent. Archived flags are hidden from `feature-flag-get-definition-by-key` and from `feature-flag-get-all`, so a flag that is already archived looks missing on a retry. Pass `{\"archived\":\"true\"}` to `feature-flag-get-all` before you report the flag as missing.\n\nUse `feature-flag-unarchive` to put the flag back in the list, or `delete-feature-flag` when the user wants it gone rather than tidied away.", + "category": "Feature flags", + "feature": "flags", + "summary": "Archive feature flag", + "title": "Archive feature flag", + "required_scopes": ["feature_flag:write"], + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "readOnlyHint": false + } + }, + "feature-flag-disable": { + "description": "Turn a feature flag off. Requires the flag's numeric ID; use `feature-flag-get-definition-by-key` to get it from a key, or `feature-flag-get-all` to search by key or name.\n\nSets `active` to false and changes nothing else. This tool never accepts or replaces the `filters` object, so targeting, rollout percentages, variants and payloads cannot change. There is no request body.\n\nA disabled flag stops evaluating everywhere, so every caller falls back to its own default. Tell the user what else this affects: linked experiments, surveys, early access features and session replay settings all appear in the flag's full definition. `feature-flag-get-definition-by-key` already returns it, and `feature-flag-get-definition` returns it by ID.\n\nReturns 400 when other active flags depend on this one; `feature-flags-dependent-flags-retrieve` lists them. Returns 409 when an approval policy gates disabling. Disabling an already-disabled flag succeeds and changes nothing, so retrying is safe.\n\nA key lookup that finds nothing does not mean the flag is absent. Archived flags are hidden from `feature-flag-get-definition-by-key` and from `feature-flag-get-all`, and an archived flag is already disabled. Pass `{\"archived\":\"true\"}` to `feature-flag-get-all` before you report the flag as missing.\n\nTo turn the flag off at a future time instead, use `scheduled-changes-create` with the `update_status` operation.", + "category": "Feature flags", + "feature": "flags", + "summary": "Disable feature flag", + "title": "Disable feature flag", + "required_scopes": ["feature_flag:write"], + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "readOnlyHint": false + } + }, + "feature-flag-enable": { + "description": "Turn a feature flag on. Requires the flag's numeric ID; use `feature-flag-get-definition-by-key` to get it from a key, or `feature-flag-get-all` to search by key or name.\n\nSets `active` to true and changes nothing else. This tool never accepts or replaces the `filters` object, so targeting, rollout percentages, variants and payloads cannot change. There is no request body. To change who the flag is served to, use `update-feature-flag`.\n\nReturns 400 when the flag is archived (call `feature-flag-unarchive` first) or when a flag it depends on is disabled. Returns 409 when an approval policy gates enabling. Enabling an already-enabled flag succeeds and changes nothing, so retrying is safe.\n\nA key lookup that finds nothing does not mean the flag is absent. Archived flags are hidden from `feature-flag-get-definition-by-key` and from `feature-flag-get-all`. Pass `{\"archived\":\"true\"}` to `feature-flag-get-all` before you report the flag as missing.\n\nTo turn the flag on at a future time instead, use `scheduled-changes-create` with the `update_status` operation.", + "category": "Feature flags", + "feature": "flags", + "summary": "Enable feature flag", + "title": "Enable feature flag", + "required_scopes": ["feature_flag:write"], + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "readOnlyHint": false + } + }, "feature-flag-get-all": { "description": "Get feature flags in the current project. Supports list filters including search by feature flag key or name (case-insensitive), then use the returned ID for get/update/delete tools.", "category": "Feature flags", @@ -5018,6 +5060,20 @@ "readOnlyHint": true } }, + "feature-flag-unarchive": { + "description": "Restore an archived feature flag to the default flag list. Requires the flag's numeric ID; pass `{\"archived\":\"true\"}` to `feature-flag-get-all` to find archived flags.\n\nSets `archived` to false and changes nothing else. This tool never accepts or replaces the `filters` object. There is no request body.\n\nThe flag stays turned off. Call `feature-flag-enable` when the user also wants it serving again. Unarchiving a flag that is not archived succeeds and changes nothing, so retrying is safe.", + "category": "Feature flags", + "feature": "flags", + "summary": "Unarchive feature flag", + "title": "Unarchive feature flag", + "required_scopes": ["feature_flag:write"], + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "readOnlyHint": false + } + }, "feature-flags-activity-retrieve": { "description": "Get the audit trail for a specific feature flag by ID. Returns a paginated list of changes including who made changes, what was changed, and when. Use limit and page query params for pagination.", "category": "Feature flags", @@ -11410,7 +11466,7 @@ "feature_flag": "tasks" }, "update-feature-flag": { - "description": "Update a feature flag by numeric ID (partial update — only the fields you include are changed). Call when the user asks to enable or disable a flag, change its rollout percentage or release conditions, edit variants or payloads, or update its description. Rollout conditions, variants, and payloads live in the structured `filters` param, and sending `filters` replaces the whole object — read the flag first with `feature-flag-get-definition` and merge your change. To apply a change at a future time instead, use `scheduled-changes-create`. Use `feature-flag-get-all` to find the numeric ID from a key.", + "description": "Update a feature flag by numeric ID (partial update: only the fields you include are changed). Use this for edits that have no dedicated tool, such as release conditions, rollout percentages, variants, payloads, tags and the description.\n\nPrefer a dedicated tool whenever the request matches one. `feature-flag-enable`, `feature-flag-disable`, `feature-flag-archive` and `feature-flag-unarchive` each change one state field and send no `filters`. A targeting change someone else made between your read and your write is therefore not overwritten. Send `active` or `archived` here only when the same request also changes targeting or another field, so the whole edit lands in one write.\n\nRollout conditions, variants, and payloads live in the structured `filters` param, and sending `filters` replaces the whole object, so read the flag first and merge your change. Use `feature-flag-get-definition-by-key` when you have the string key: it returns the numeric ID and the full definition in one call. Use `feature-flag-get-definition` when you already have the ID. To apply a change at a future time instead, use `scheduled-changes-create`.", "category": "Feature flags", "feature": "flags", "summary": "Update feature flag", diff --git a/services/mcp/src/generated/feature_flags/api.ts b/services/mcp/src/generated/feature_flags/api.ts index 202050e600fb..785642981058 100644 --- a/services/mcp/src/generated/feature_flags/api.ts +++ b/services/mcp/src/generated/feature_flags/api.ts @@ -3,7 +3,7 @@ * MCP service uses these Zod schemas for generated tool handlers. * To regenerate: hogli build:openapi * - * PostHog API - MCP 21 enabled ops + * PostHog API - MCP 25 enabled ops * OpenAPI spec version: 1.0.0 */ import * as zod from 'zod' @@ -891,6 +891,24 @@ export const FeatureFlagsActivityRetrieveQueryParams = () => zod.object({ page: zod.number().min(1).default(featureFlagsActivityRetrieveQueryPageDefault).describe('Page number'), }) +/** + * Archive a feature flag, hiding it from the default flag list. + * + * Sets `archived` to true. An archived flag must be disabled, so an enabled flag also gets + * `active` set to false in the same write. Targeting, variants and payloads are left as + * they are, and linked experiment and survey history is preserved. Archiving an enabled + * flag is refused when other active flags depend on it. An already-archived flag is + * returned unchanged. + */ +export const FeatureFlagsArchiveCreateParams = () => zod.object({ + id: zod.number().describe('A unique integer value identifying this feature flag.'), + project_id: zod + .string() + .describe( + "Project ID of the project you're trying to access. To find the ID of the project, make a call to \/api\/projects\/." + ), +}) + /** * Get other active flags that depend on this flag. */ @@ -903,6 +921,42 @@ export const FeatureFlagsDependentFlagsListParams = () => zod.object({ ), }) +/** + * Disable a feature flag. + * + * Sets `active` to false and changes nothing else. Targeting, variants, payloads, tags and + * archived state are left as they are. Refused when other active flags depend on this one. + * An already-disabled flag is returned unchanged. + * + * A disabled flag stops evaluating for every consumer, including a linked experiment or a + * session replay setting. Read the full definition first to report that impact. + */ +export const FeatureFlagsDisableCreateParams = () => zod.object({ + id: zod.number().describe('A unique integer value identifying this feature flag.'), + project_id: zod + .string() + .describe( + "Project ID of the project you're trying to access. To find the ID of the project, make a call to \/api\/projects\/." + ), +}) + +/** + * Enable a feature flag. + * + * Sets `active` to true and changes nothing else. Targeting, variants, payloads, tags and + * archived state are left as they are. An archived flag is refused: unarchive it first. A + * flag whose own flag dependencies are disabled is also refused. An already-enabled flag + * is returned unchanged. + */ +export const FeatureFlagsEnableCreateParams = () => zod.object({ + id: zod.number().describe('A unique integer value identifying this feature flag.'), + project_id: zod + .string() + .describe( + "Project ID of the project you're trying to access. To find the ID of the project, make a call to \/api\/projects\/." + ), +}) + /** * Create, read, update and delete feature flags. [See docs](https://posthog.com/docs/feature-flags) for more information on feature flags. * @@ -951,6 +1005,21 @@ export const FeatureFlagsTestEvaluationCreateBody = () => zod.object({ .describe('Groups for feature flag evaluation (JSON object, defaults to empty dict)'), }) +/** + * Restore an archived feature flag to the default flag list. + * + * Sets `archived` to false and changes nothing else. The flag stays disabled; enable it + * with a separate call. An already-unarchived flag is returned unchanged. + */ +export const FeatureFlagsUnarchiveCreateParams = () => zod.object({ + id: zod.number().describe('A unique integer value identifying this feature flag.'), + project_id: zod + .string() + .describe( + "Project ID of the project you're trying to access. To find the ID of the project, make a call to \/api\/projects\/." + ), +}) + /** * Bulk delete feature flags by filter criteria or explicit IDs. * diff --git a/services/mcp/src/lib/instructions.ts b/services/mcp/src/lib/instructions.ts index 0f532dd562e6..2ff6ed716e2e 100644 --- a/services/mcp/src/lib/instructions.ts +++ b/services/mcp/src/lib/instructions.ts @@ -232,6 +232,7 @@ export class ToolDomainExtractor { 'calculate', 'complete', 'copy', + 'disable', 'discard', 'duplicate', 'edit', diff --git a/services/mcp/src/tools/generated/feature_flags.ts b/services/mcp/src/tools/generated/feature_flags.ts index 430f2d449a04..fff91d492b2b 100644 --- a/services/mcp/src/tools/generated/feature_flags.ts +++ b/services/mcp/src/tools/generated/feature_flags.ts @@ -96,6 +96,99 @@ const deleteFeatureFlag = (): ToolBase { + const FeatureFlagsArchiveCreateParams = orvalSchemas.FeatureFlagsArchiveCreateParams() + return FeatureFlagsArchiveCreateParams.omit({ project_id: true }).extend({ + id: z.preprocess(castStringToInt, FeatureFlagsArchiveCreateParams.shape['id']), + }) +} + +const featureFlagArchive = (): ToolBase< + ReturnType, + WithPostHogUrl +> => ({ + name: 'feature-flag-archive', + schema: FeatureFlagArchiveSchema(), + handler: async (context: Context, params: z.infer>) => { + const projectId = await context.stateManager.getProjectId() + const result = await context.api.request({ + method: 'POST', + path: `/api/projects/${encodeURIComponent(String(projectId))}/feature_flags/${encodeURIComponent(String(params.id))}/archive/`, + }) + const filtered = pickResponseFields(result, [ + 'id', + 'key', + 'active', + 'archived', + 'status', + 'version', + ]) as typeof result + return await withPostHogUrl(context, filtered, `/feature_flags/${filtered.id}`) + }, +}) + +const FeatureFlagDisableSchema = () => { + const FeatureFlagsDisableCreateParams = orvalSchemas.FeatureFlagsDisableCreateParams() + return FeatureFlagsDisableCreateParams.omit({ project_id: true }).extend({ + id: z.preprocess(castStringToInt, FeatureFlagsDisableCreateParams.shape['id']), + }) +} + +const featureFlagDisable = (): ToolBase< + ReturnType, + WithPostHogUrl +> => ({ + name: 'feature-flag-disable', + schema: FeatureFlagDisableSchema(), + handler: async (context: Context, params: z.infer>) => { + const projectId = await context.stateManager.getProjectId() + const result = await context.api.request({ + method: 'POST', + path: `/api/projects/${encodeURIComponent(String(projectId))}/feature_flags/${encodeURIComponent(String(params.id))}/disable/`, + }) + const filtered = pickResponseFields(result, [ + 'id', + 'key', + 'active', + 'archived', + 'status', + 'version', + ]) as typeof result + return await withPostHogUrl(context, filtered, `/feature_flags/${filtered.id}`) + }, +}) + +const FeatureFlagEnableSchema = () => { + const FeatureFlagsEnableCreateParams = orvalSchemas.FeatureFlagsEnableCreateParams() + return FeatureFlagsEnableCreateParams.omit({ project_id: true }).extend({ + id: z.preprocess(castStringToInt, FeatureFlagsEnableCreateParams.shape['id']), + }) +} + +const featureFlagEnable = (): ToolBase< + ReturnType, + WithPostHogUrl +> => ({ + name: 'feature-flag-enable', + schema: FeatureFlagEnableSchema(), + handler: async (context: Context, params: z.infer>) => { + const projectId = await context.stateManager.getProjectId() + const result = await context.api.request({ + method: 'POST', + path: `/api/projects/${encodeURIComponent(String(projectId))}/feature_flags/${encodeURIComponent(String(params.id))}/enable/`, + }) + const filtered = pickResponseFields(result, [ + 'id', + 'key', + 'active', + 'archived', + 'status', + 'version', + ]) as typeof result + return await withPostHogUrl(context, filtered, `/feature_flags/${filtered.id}`) + }, +}) + const FeatureFlagGetAllSchema = () => { const FeatureFlagsListQueryParams = orvalSchemas.FeatureFlagsListQueryParams() return FeatureFlagsListQueryParams.extend({ @@ -177,6 +270,37 @@ const featureFlagGetDefinition = (): ToolBase< }, }) +const FeatureFlagUnarchiveSchema = () => { + const FeatureFlagsUnarchiveCreateParams = orvalSchemas.FeatureFlagsUnarchiveCreateParams() + return FeatureFlagsUnarchiveCreateParams.omit({ project_id: true }).extend({ + id: z.preprocess(castStringToInt, FeatureFlagsUnarchiveCreateParams.shape['id']), + }) +} + +const featureFlagUnarchive = (): ToolBase< + ReturnType, + WithPostHogUrl +> => ({ + name: 'feature-flag-unarchive', + schema: FeatureFlagUnarchiveSchema(), + handler: async (context: Context, params: z.infer>) => { + const projectId = await context.stateManager.getProjectId() + const result = await context.api.request({ + method: 'POST', + path: `/api/projects/${encodeURIComponent(String(projectId))}/feature_flags/${encodeURIComponent(String(params.id))}/unarchive/`, + }) + const filtered = pickResponseFields(result, [ + 'id', + 'key', + 'active', + 'archived', + 'status', + 'version', + ]) as typeof result + return await withPostHogUrl(context, filtered, `/feature_flags/${filtered.id}`) + }, +}) + const FeatureFlagsActivityRetrieveSchema = () => { const FeatureFlagsActivityRetrieveParams = orvalSchemas.FeatureFlagsActivityRetrieveParams() const FeatureFlagsActivityRetrieveQueryParams = orvalSchemas.FeatureFlagsActivityRetrieveQueryParams() @@ -749,8 +873,12 @@ const updateFeatureFlag = (): ToolBase< export const GENERATED_TOOLS: Record ToolBase> = { 'create-feature-flag': createFeatureFlag, 'delete-feature-flag': deleteFeatureFlag, + 'feature-flag-archive': featureFlagArchive, + 'feature-flag-disable': featureFlagDisable, + 'feature-flag-enable': featureFlagEnable, 'feature-flag-get-all': featureFlagGetAll, 'feature-flag-get-definition': featureFlagGetDefinition, + 'feature-flag-unarchive': featureFlagUnarchive, 'feature-flags-activity-retrieve': featureFlagsActivityRetrieve, 'feature-flags-bulk-delete-create': featureFlagsBulkDeleteCreate, 'feature-flags-bulk-keys-retrieve': featureFlagsBulkKeysRetrieve, diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/feature-flag-archive.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/feature-flag-archive.json new file mode 100644 index 000000000000..88cc27e940e9 --- /dev/null +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/feature-flag-archive.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "id": { + "description": "A unique integer value identifying this feature flag.", + "type": "number" + } + }, + "required": ["id"], + "type": "object" +} diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/feature-flag-disable.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/feature-flag-disable.json new file mode 100644 index 000000000000..88cc27e940e9 --- /dev/null +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/feature-flag-disable.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "id": { + "description": "A unique integer value identifying this feature flag.", + "type": "number" + } + }, + "required": ["id"], + "type": "object" +} diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/feature-flag-enable.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/feature-flag-enable.json new file mode 100644 index 000000000000..88cc27e940e9 --- /dev/null +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/feature-flag-enable.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "id": { + "description": "A unique integer value identifying this feature flag.", + "type": "number" + } + }, + "required": ["id"], + "type": "object" +} diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/feature-flag-unarchive.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/feature-flag-unarchive.json new file mode 100644 index 000000000000..88cc27e940e9 --- /dev/null +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/feature-flag-unarchive.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "id": { + "description": "A unique integer value identifying this feature flag.", + "type": "number" + } + }, + "required": ["id"], + "type": "object" +} diff --git a/services/mcp/tests/unit/feature-flag-lifecycle-tools.test.ts b/services/mcp/tests/unit/feature-flag-lifecycle-tools.test.ts new file mode 100644 index 000000000000..ecd724bb8fb5 --- /dev/null +++ b/services/mcp/tests/unit/feature-flag-lifecycle-tools.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' + +import { GENERATED_TOOL_MAP } from '@/tools/generated' +import { getToolDefinition } from '@/tools/toolDefinitions' +import type { Context } from '@/tools/types' + +const LIFECYCLE_TOOLS = [ + 'feature-flag-enable', + 'feature-flag-disable', + 'feature-flag-archive', + 'feature-flag-unarchive', +] + +describe('feature flag lifecycle tools', () => { + // The snapshot test would also catch a `filters` param appearing here, but it is + // silenced by `vitest -u`. These assertions are not: the whole point of the lifecycle + // tools is that an agent cannot send targeting through them. + it.each(LIFECYCLE_TOOLS)('%s takes the flag id and nothing else', (name) => { + const tool = GENERATED_TOOL_MAP[name]?.() + expect(tool, `${name} is missing from the generated tool map`).not.toBeUndefined() + + const schema = z.toJSONSchema(tool!.schema, { io: 'input', reused: 'inline' }) as { + properties?: Record + required?: string[] + } + + expect(Object.keys(schema.properties ?? {})).toEqual(['id']) + expect(schema.required).toEqual(['id']) + }) + + it.each(LIFECYCLE_TOOLS)('%s declares the write scope and a reversible, idempotent action', (name) => { + const definition = getToolDefinition(name) + + expect(definition.description).toContain('never accepts or replaces the `filters` object') + expect(definition.required_scopes).toEqual(['feature_flag:write']) + expect(definition.annotations?.readOnlyHint).toBe(false) + expect(definition.annotations?.destructiveHint).toBe(false) + expect(definition.annotations?.idempotentHint).toBe(true) + }) + + // The tool name and the URL it posts to come from separate lines of the same YAML entry, + // so nothing above notices `feature-flag-enable` wired to `/disable/`. An agent asked to + // turn a flag on would turn it off. + it.each(LIFECYCLE_TOOLS)('%s posts to its own endpoint', async (name) => { + const requestMock = vi.fn().mockResolvedValue({ id: 42 }) + const context = { + api: { + request: requestMock, + getProjectBaseUrl: () => 'https://app.posthog.com/project/17', + }, + stateManager: { getProjectId: vi.fn().mockResolvedValue('17') }, + } as unknown as Context + + await GENERATED_TOOL_MAP[name]!().handler(context, { id: 42 }) + + expect(requestMock).toHaveBeenCalledWith({ + method: 'POST', + path: `/api/projects/17/feature_flags/42/${name.replace('feature-flag-', '')}/`, + }) + }) +}) diff --git a/services/mcp/tests/unit/instructions.test.ts b/services/mcp/tests/unit/instructions.test.ts index 96234e3a12ca..41e3c1f29a31 100644 --- a/services/mcp/tests/unit/instructions.test.ts +++ b/services/mcp/tests/unit/instructions.test.ts @@ -157,6 +157,7 @@ describe('buildToolDomainsBlock', () => { 'calculate', 'complete', 'copy', + 'disable', 'discard', 'duplicate', 'edit',