From e1dfbe59fd4258732e9139c677b35f165e6590e9 Mon Sep 17 00:00:00 2001 From: Ravindra Singh Shekhawat Date: Mon, 17 Aug 2026 23:12:55 +0530 Subject: [PATCH 1/8] feat: wire native FME segment update and segment definition keys Native segment PATCH and definition key execute actions were the remaining v4 gaps after #806; point native fme_segment_keys callers at those actions instead of advertising a fake NYI native keys resource. Co-authored-by: Cursor --- src/registry/toolsets/feature-flags.ts | 143 +++++++++++- tests/registry/feature-flags.test.ts | 15 +- tests/registry/fme-segment-remaining.test.ts | 229 +++++++++++++++++++ 3 files changed, 371 insertions(+), 16 deletions(-) create mode 100644 tests/registry/fme-segment-remaining.test.ts diff --git a/src/registry/toolsets/feature-flags.ts b/src/registry/toolsets/feature-flags.ts index ac5daf6a..78a8a0bd 100644 --- a/src/registry/toolsets/feature-flags.ts +++ b/src/registry/toolsets/feature-flags.ts @@ -111,6 +111,56 @@ const fmeSegmentCreateSchema: BodySchema = { ], }; +const fmeSegmentUpdateSchema: BodySchema = { + description: "Partial update for a Harness-native segment via JSON Merge Patch (RFC 7396). Omit a field to leave it unchanged; set description/tags/owners to null (or [] for tags/owners) to clear.", + fields: [ + { name: "description", type: "string", required: false, description: "Updated description; null clears it" }, + { name: "tags", type: "array", required: false, description: "Updated tags — [{name: 'tag1'}] or ['tag1']; null or [] clears", itemType: "object" }, + { name: "owners", type: "array", required: false, description: "Updated owners — {type: \"USER\", id or email} or {type: \"GROUP\", identifier}; null or [] clears", itemType: "object" }, + ], +}; + +const fmeSegmentDefinitionKeysAddSchema: BodySchema = { + description: "Add or replace membership keys on a segment definition. Body.keys is required (empty allowed only with replace=true). Optional comment/title.", + fields: [ + { name: "keys", type: "array", required: true, description: "Identity keys to add (or the full set when replace=true). Max 10000.", itemType: "string" }, + { name: "comment", type: "string", required: false, description: "Optional comment recorded with the change" }, + { name: "title", type: "string", required: false, description: "Optional short title for the change" }, + ], +}; + +const fmeSegmentDefinitionKeysRemoveSchema: BodySchema = { + description: "Remove membership keys from a segment definition. Body.keys must contain at least one key. Optional comment/title.", + fields: [ + { name: "keys", type: "array", required: true, description: "Identity keys to remove (min 1, max 10000)", itemType: "string" }, + { name: "comment", type: "string", required: false, description: "Optional comment recorded with the change" }, + { name: "title", type: "string", required: false, description: "Optional short title for the change" }, + ], +}; + +function fmeSegmentKeysNativePointer(operation: string): never { + throw new Error( + `fme_segment_keys.${operation}: Harness-native membership uses fme_segment_definition execute actions list_keys, add_keys, and remove_keys — do not pass org_id+project_id on fme_segment_keys.`, + ); +} + +function fmeSegmentDefinitionKeysBody(input: Record, opts: { requireNonEmpty: boolean }): Record { + const body = input.body as Record | undefined; + const keys = body?.keys; + if (!Array.isArray(keys) || (opts.requireNonEmpty && keys.length === 0)) { + throw new Error( + opts.requireNonEmpty + ? "fme_segment_definition.remove_keys requires body.keys with at least one key." + : "fme_segment_definition.add_keys requires body.keys (array; empty only when replace=true).", + ); + } + return { + keys, + ...(body?.comment !== undefined ? { comment: body.comment } : {}), + ...(body?.title !== undefined ? { title: body.title } : {}), + }; +} + const fmeSegmentDefinitionCreateSchema: BodySchema = { description: "Create a segment definition in an environment. Omit the body (or send {}) to create an empty/default shell.", fields: [{ name: "description", type: "string", required: false, description: "Optional description for the definition" }], @@ -1097,7 +1147,7 @@ export const featureFlagsToolset: ToolsetDefinition = { resourceType: "fme_segment", displayName: "FME Segment", description: - "FME (Harness-native, org_id+project_id scoped). Unified segment type (standard, rule-based, and large). Supports list, get, create, delete. create requires body.type (\"standard\" | \"rule_based\" | \"large\").", + "FME (Harness-native, org_id+project_id scoped). Unified segment type (standard, rule-based, and large). Supports list, get, create, update (JSON Merge Patch on description/tags/owners), and delete. create requires body.type (\"standard\" | \"rule_based\" | \"large\").", toolset: "feature-flags", scope: "project", scopeParams: FME_HARNESS_NATIVE_SCOPE_PARAMS, @@ -1171,6 +1221,32 @@ export const featureFlagsToolset: ToolsetDefinition = { bodySchema: fmeSegmentCreateSchema, description: "Create a new segment. Body requires name + trafficType + type (\"standard\" | \"rule_based\" | \"large\"); optional description, tags, owners.", }, + update: { + method: "PATCH", + path: "", + routeResolver: (input) => { + requireHarnessNativeSegmentScope(input, "fme_segment"); + const segmentName = encodeURIComponent(requireFmeIdentifier(input, "segment_name", "fme_segment")); + return { + path: `/fme/api/v4/segments/${segmentName}`, + headers: { "Content-Type": "application/merge-patch+json" }, + }; + }, + operationPolicy: { risk: "low_write", retryPolicy: "safe" }, + bodyBuilder: (input) => { + const body = input.body as Record | undefined; + if (!body) return {}; + return { + ...(body.description !== undefined ? { description: body.description } : {}), + ...(body.tags !== undefined ? { tags: body.tags === null ? null : normalizeFmeTags(body.tags) } : {}), + ...(body.owners !== undefined ? { owners: body.owners } : {}), + }; + }, + responseExtractor: passthrough, + bodySchema: fmeSegmentUpdateSchema, + description: + "Update a segment's description, tags, and/or owners via JSON Merge Patch (RFC 7396). Harness-native only (org_id+project_id). Omit a field to leave it unchanged; set description/tags/owners to null (or [] for tags/owners) to clear.", + }, }, }, // ── FME Segment Definition (Harness-native, unified — Harness_Split/Main PR #12644) ── @@ -1178,7 +1254,7 @@ export const featureFlagsToolset: ToolsetDefinition = { resourceType: "fme_segment_definition", displayName: "FME Segment Definition", description: - "Environment-specific definition of a segment (standard or rule-based) — description and lifecycle. Replaces fme_rule_based_segment_definition's role in Harness-native calls, generalized for all segment types. Harness-native only (org_id+project_id; no legacy workspace_id support). Supports list, get, create, update (description only, via JSON Merge Patch), and delete. Wired against Harness_Split/Main PR #12644, which was open (not yet merged) as of this writing — paths may still change before merge. There is no enable/disable/change_request action: the backend has no such endpoints for this unified resource; governance checks are surfaced inline in the create/update/delete responses instead.", + "Environment-specific definition of a segment (standard or rule-based) — description, lifecycle, and membership keys. Replaces fme_rule_based_segment_definition's role in Harness-native calls, generalized for all segment types. Harness-native only (org_id+project_id; no legacy workspace_id support). Supports list, get, create, update (description only, via JSON Merge Patch), delete, and execute list_keys/add_keys/remove_keys. There is no enable/disable/change_request action: the backend has no such endpoints for this unified resource; governance checks are surfaced inline in the create/update/delete responses instead.", toolset: "feature-flags", scope: "project", scopeParams: FME_HARNESS_NATIVE_SCOPE_PARAMS, @@ -1267,12 +1343,63 @@ export const featureFlagsToolset: ToolsetDefinition = { description: "Delete a segment definition from an environment.", }, }, + executeActions: { + list_keys: { + method: "GET", + path: "", + routeResolver: (input) => { + requireHarnessNativeSegmentScope(input, "fme_segment_definition"); + const segmentName = encodeURIComponent(requireFmeIdentifier(input, "segment_name", "fme_segment_definition")); + requireFmeIdentifier(input, "environment_id", "fme_segment_definition"); + return { path: `/fme/api/v4/segment-definitions/${segmentName}/keys` }; + }, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + queryParams: { environment_id: "environment_id", offset: "offset", limit: "limit" }, + responseExtractor: passthrough, + actionDescription: + "List membership keys for a segment definition in an environment (Harness-native). Requires org_id, project_id, segment_name, and environment_id. Pagination: offset and limit (default 100, max 100).", + }, + add_keys: { + method: "POST", + path: "", + routeResolver: (input) => { + requireHarnessNativeSegmentScope(input, "fme_segment_definition"); + const segmentName = encodeURIComponent(requireFmeIdentifier(input, "segment_name", "fme_segment_definition")); + requireFmeIdentifier(input, "environment_id", "fme_segment_definition"); + return { path: `/fme/api/v4/segment-definitions/${segmentName}/keys` }; + }, + operationPolicy: { risk: "medium_write", retryPolicy: "do_not_retry" }, + queryParams: { environment_id: "environment_id", replace: "replace" }, + bodyBuilder: (input) => fmeSegmentDefinitionKeysBody(input, { requireNonEmpty: false }), + responseExtractor: passthrough, + bodySchema: fmeSegmentDefinitionKeysAddSchema, + actionDescription: + "Add membership keys to a segment definition (Harness-native). Requires org_id, project_id, segment_name, environment_id, and body.keys. Pass replace=true to replace the full key set (empty keys allowed only then). Optional body comment/title.", + }, + remove_keys: { + method: "POST", + path: "", + routeResolver: (input) => { + requireHarnessNativeSegmentScope(input, "fme_segment_definition"); + const segmentName = encodeURIComponent(requireFmeIdentifier(input, "segment_name", "fme_segment_definition")); + requireFmeIdentifier(input, "environment_id", "fme_segment_definition"); + return { path: `/fme/api/v4/segment-definitions/${segmentName}/keys/remove` }; + }, + operationPolicy: { risk: "medium_write", retryPolicy: "do_not_retry" }, + queryParams: { environment_id: "environment_id" }, + bodyBuilder: (input) => fmeSegmentDefinitionKeysBody(input, { requireNonEmpty: true }), + responseExtractor: passthrough, + bodySchema: fmeSegmentDefinitionKeysRemoveSchema, + actionDescription: + "Remove membership keys from a segment definition (Harness-native). Requires org_id, project_id, segment_name, environment_id, and body.keys with at least one key. Optional body comment/title.", + }, + }, }, { resourceType: "fme_segment_keys", displayName: "FME Segment Keys", description: - "Membership keys (members) of a standard segment. List keys with pagination, or update to add members. Removal is not supported by this endpoint. Limit: 10,000 keys per request, 100,000 per segment total.", + "Membership keys (members) of a standard segment. List keys with pagination, or update to add members. Removal is not supported by this endpoint. Limit: 10,000 keys per request, 100,000 per segment total. Legacy workspace-style calls use environment_id+segment_name without org_id/project_id. Harness-native membership is not on this resource — use fme_segment_definition execute actions list_keys, add_keys, and remove_keys.", toolset: "feature-flags", scope: "account", scopeOptional: true, @@ -1282,8 +1409,6 @@ export const featureFlagsToolset: ToolsetDefinition = { { name: "environment_id", description: "Environment ID (get from fme_environment)", required: true }, { name: "segment_name", description: "Segment name", required: true }, { name: "offset", description: "Pagination offset", type: "number" }, - { name: "org_id", description: "Optional — pass together with project_id to select the (not yet implemented) Harness-native mode instead of the current contract." }, - { name: "project_id", description: "Optional — pass together with org_id to select the (not yet implemented) Harness-native mode instead of the current contract." }, ], operations: { list: { @@ -1291,9 +1416,7 @@ export const featureFlagsToolset: ToolsetDefinition = { path: "/internal/api/v2/segments/{environmentId}/{segmentName}/keys", routeResolver: (input) => { if (isFmeHarnessNativeSelected(input, "fme_segment_keys.list")) { - throw new Error( - "fme_segment_keys.list: Harness-native (org_id/project_id) mode not yet implemented for this operation — pass environment_id/segment_name (current contract) instead.", - ); + fmeSegmentKeysNativePointer("list"); } const environmentId = encodeURIComponent(requireFmeIdentifier(input, "environment_id", "fme_segment_keys")); const segmentName = encodeURIComponent(requireFmeIdentifier(input, "segment_name", "fme_segment_keys")); @@ -1313,9 +1436,7 @@ export const featureFlagsToolset: ToolsetDefinition = { path: "/internal/api/v2/segments/{environmentId}/{segmentName}/upload", routeResolver: (input) => { if (isFmeHarnessNativeSelected(input, "fme_segment_keys.update")) { - throw new Error( - "fme_segment_keys.update: Harness-native (org_id/project_id) mode not yet implemented for this operation — pass environment_id/segment_name (current contract) instead.", - ); + fmeSegmentKeysNativePointer("update"); } const environmentId = encodeURIComponent(requireFmeIdentifier(input, "environment_id", "fme_segment_keys")); const segmentName = encodeURIComponent(requireFmeIdentifier(input, "segment_name", "fme_segment_keys")); diff --git a/tests/registry/feature-flags.test.ts b/tests/registry/feature-flags.test.ts index cd79426a..7ed2d888 100644 --- a/tests/registry/feature-flags.test.ts +++ b/tests/registry/feature-flags.test.ts @@ -1278,7 +1278,7 @@ describe("fme_segment_keys permissive mode-selector", () => { expect(firstRequest(mockRequest).path).toBe("/internal/api/v2/segments/env-prod/beta_users/keys"); }); - it("org_id+project_id throws not-yet-implemented for list", async () => { + it("org_id+project_id points list at fme_segment_definition execute actions", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); @@ -1289,7 +1289,7 @@ describe("fme_segment_keys permissive mode-selector", () => { org_id: "o1", project_id: "p1", }), - ).rejects.toThrow(/not yet implemented/i); + ).rejects.toThrow("fme_segment_definition execute actions list_keys, add_keys, and remove_keys"); }); it("legacy mode still works for update", async () => { @@ -1306,7 +1306,7 @@ describe("fme_segment_keys permissive mode-selector", () => { expect(firstRequest(mockRequest).path).toBe("/internal/api/v2/segments/env-prod/beta_users/upload"); }); - it("org_id+project_id throws not-yet-implemented for update", async () => { + it("org_id+project_id points update at fme_segment_definition execute actions", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); @@ -1318,7 +1318,7 @@ describe("fme_segment_keys permissive mode-selector", () => { project_id: "p1", body: { add: ["user-1"] }, }), - ).rejects.toThrow(/not yet implemented/i); + ).rejects.toThrow("fme_segment_definition execute actions list_keys, add_keys, and remove_keys"); }); }); @@ -1620,6 +1620,11 @@ describe("fme_segment_definition", () => { it("has no enable/disable/change_request execute actions", () => { const resource = findResource("fme_segment_definition"); - expect(resource.executeActions).toBeUndefined(); + expect(resource.executeActions).not.toHaveProperty("enable"); + expect(resource.executeActions).not.toHaveProperty("disable"); + expect(resource.executeActions).not.toHaveProperty("change_request"); + expect(resource.executeActions).toHaveProperty("list_keys"); + expect(resource.executeActions).toHaveProperty("add_keys"); + expect(resource.executeActions).toHaveProperty("remove_keys"); }); }); diff --git a/tests/registry/fme-segment-remaining.test.ts b/tests/registry/fme-segment-remaining.test.ts new file mode 100644 index 00000000..8e66cad8 --- /dev/null +++ b/tests/registry/fme-segment-remaining.test.ts @@ -0,0 +1,229 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Config } from "../../src/config.js"; +import type { HarnessClient } from "../../src/client/harness-client.js"; +import type { RequestOptions } from "../../src/client/types.js"; +import { Registry } from "../../src/registry/index.js"; + +function makeConfig(overrides: Partial = {}): Config { + return { + HARNESS_MCP_MODE: "single-user", + HARNESS_API_KEY: "pat.test", + HARNESS_ACCOUNT_ID: "test-account", + HARNESS_BASE_URL: "https://app.harness.io", + HARNESS_ORG: "default", + HARNESS_PROJECT: "test-project", + HARNESS_API_TIMEOUT_MS: 30000, + HARNESS_MAX_RETRIES: 3, + LOG_LEVEL: "info", + HARNESS_TOOLSETS: "feature-flags", + HARNESS_MAX_BODY_SIZE_MB: 10, + HARNESS_RATE_LIMIT_RPS: 10, + HARNESS_READ_ONLY: false, + HARNESS_SKIP_ELICITATION: false, + HARNESS_AUTO_APPROVE_RISK: "none", + HARNESS_ALLOW_HTTP: false, + HARNESS_MCP_ALLOWED_HOSTS: undefined, + HARNESS_MCP_AUTH_TOKEN: undefined, + HARNESS_MCP_ALLOW_UNAUTHENTICATED_HTTP: false, + HARNESS_FME_API_KEY: undefined, + HARNESS_FME_BASE_URL: "https://api.split.io", + HARNESS_LOG_UNSAFE_BODIES: false, + HARNESS_PIPELINE_VERSION: undefined, + HARNESS_AUDIT_FILE: undefined, + HARNESS_AUDIT_WEBHOOK_URL: undefined, + HARNESS_AUDIT_WEBHOOK_TOKEN: undefined, + HARNESS_AUDIT_WEBHOOK_BATCH_SIZE: 10, + HARNESS_AUDIT_WEBHOOK_FLUSH_MS: 5000, + ...overrides, + }; +} + +function makeClient(requestFn?: (options: RequestOptions) => Promise): HarnessClient { + return { + request: requestFn ?? vi.fn().mockResolvedValue({}), + account: "test-account", + } as unknown as HarnessClient; +} + +function firstRequest(mockRequest: ReturnType): RequestOptions { + return mockRequest.mock.calls[0][0] as RequestOptions; +} + +const nativeScope = { org_id: "o1", project_id: "p1" }; +const KEYS_POINTER = + "fme_segment_definition execute actions list_keys, add_keys, and remove_keys"; + +describe("fme_segment remaining native update", () => { + let registry: Registry; + + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it("patches a segment via merge-patch with description tags and owners", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_segment", "update", { + ...nativeScope, + segment_name: "seg1", + body: { description: "updated", tags: ["beta"], owners: [{ type: "USER", email: "a@b.c" }] }, + }); + + const request = firstRequest(mockRequest); + expect(request.method).toBe("PATCH"); + expect(request.path).toBe("/fme/api/v4/segments/seg1"); + expect(request.headers).toMatchObject({ "Content-Type": "application/merge-patch+json" }); + expect(request.params).toMatchObject({ + account_id: "test-account", + organization_identifier: "o1", + project_identifier: "p1", + }); + expect(request.body).toEqual({ + description: "updated", + tags: [{ name: "beta" }], + owners: [{ type: "USER", email: "a@b.c" }], + }); + }); + + it("omits unset merge-patch fields and forwards explicit nulls", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_segment", "update", { + ...nativeScope, + segment_name: "seg1", + body: { description: null, tags: [] }, + }); + + expect(firstRequest(mockRequest).body).toEqual({ description: null, tags: [] }); + }); + + it("rejects workspace_id because fme_segment is Harness-native only", async () => { + const client = makeClient(); + + await expect( + registry.dispatch(client, "fme_segment", "update", { + workspace_id: "ws1", + segment_name: "seg1", + body: { description: "x" }, + }), + ).rejects.toThrow("fme_segment: org_id and project_id are required (account is taken from config)."); + }); +}); + +describe("fme_segment_definition remaining key execute actions", () => { + let registry: Registry; + + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it("lists keys with environment_id limit and offset", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatchExecute(client, "fme_segment_definition", "list_keys", { + ...nativeScope, + segment_name: "seg1", + environment_id: "env1", + limit: 25, + offset: 10, + }); + + const request = firstRequest(mockRequest); + expect(request.method).toBe("GET"); + expect(request.path).toBe("/fme/api/v4/segment-definitions/seg1/keys"); + expect(request.path).not.toContain("/environments/"); + expect(request.params).toMatchObject({ + environment_id: "env1", + limit: 25, + offset: 10, + organization_identifier: "o1", + project_identifier: "p1", + }); + }); + + it("adds keys with optional replace query and comment title", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatchExecute(client, "fme_segment_definition", "add_keys", { + ...nativeScope, + segment_name: "seg1", + environment_id: "env1", + replace: true, + body: { keys: ["a", "b"], comment: "c", title: "t" }, + }); + + const request = firstRequest(mockRequest); + expect(request.method).toBe("POST"); + expect(request.path).toBe("/fme/api/v4/segment-definitions/seg1/keys"); + expect(request.params).toMatchObject({ environment_id: "env1", replace: true }); + expect(request.body).toEqual({ keys: ["a", "b"], comment: "c", title: "t" }); + }); + + it("removes keys via POST keys/remove", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatchExecute(client, "fme_segment_definition", "remove_keys", { + ...nativeScope, + segment_name: "seg1", + environment_id: "env1", + body: { keys: ["a"], comment: "c", title: "t" }, + }); + + const request = firstRequest(mockRequest); + expect(request.method).toBe("POST"); + expect(request.path).toBe("/fme/api/v4/segment-definitions/seg1/keys/remove"); + expect(request.params).toMatchObject({ environment_id: "env1" }); + expect(request.body).toEqual({ keys: ["a"], comment: "c", title: "t" }); + }); + + it("requires keys array on remove_keys", async () => { + const client = makeClient(); + + await expect( + registry.dispatchExecute(client, "fme_segment_definition", "remove_keys", { + ...nativeScope, + segment_name: "seg1", + environment_id: "env1", + body: { keys: [] }, + }), + ).rejects.toThrow(/keys/); + }); +}); + +describe("fme_segment_keys native pointer", () => { + let registry: Registry; + + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it("points native list at segment definition execute actions", async () => { + const client = makeClient(); + + await expect( + registry.dispatch(client, "fme_segment_keys", "list", { + ...nativeScope, + environment_id: "env1", + segment_name: "seg1", + }), + ).rejects.toThrow(KEYS_POINTER); + }); + + it("points native update at segment definition execute actions", async () => { + const client = makeClient(); + + await expect( + registry.dispatch(client, "fme_segment_keys", "update", { + ...nativeScope, + environment_id: "env1", + segment_name: "seg1", + body: { add: ["a"] }, + }), + ).rejects.toThrow(KEYS_POINTER); + }); +}); From 6543b1629c4ca82400d0ebd95fe9bc85f1cbf07d Mon Sep 17 00:00:00 2001 From: Ravindra Singh Shekhawat Date: Mon, 17 Aug 2026 23:18:20 +0530 Subject: [PATCH 2/8] fix: skip NG scope fields on native FME segment POST bodies Harness NG orgIdentifier/projectIdentifier injection made v4 create and key-mutation JSON fail validation. Co-authored-by: Cursor --- src/registry/toolsets/feature-flags.ts | 4 ++++ tests/registry/fme-segment-remaining.test.ts | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/registry/toolsets/feature-flags.ts b/src/registry/toolsets/feature-flags.ts index 78a8a0bd..c5804694 100644 --- a/src/registry/toolsets/feature-flags.ts +++ b/src/registry/toolsets/feature-flags.ts @@ -1200,6 +1200,7 @@ export const featureFlagsToolset: ToolsetDefinition = { return { path: "/fme/api/v4/segments" }; }, operationPolicy: { risk: "low_write", retryPolicy: "do_not_retry" }, + skipScopeBodyInjection: true, bodyBuilder: (input) => { const body = input.body as Record | undefined; const trafficType = (body?.trafficType ?? body?.traffic_type) as string | undefined; @@ -1300,6 +1301,7 @@ export const featureFlagsToolset: ToolsetDefinition = { return { path: `/fme/api/v4/segment-definitions/${segmentName}` }; }, operationPolicy: { risk: "low_write", retryPolicy: "do_not_retry" }, + skipScopeBodyInjection: true, queryParams: { environment_id: "environment_id" }, bodyBuilder: (input) => { const body = input.body as Record | undefined; @@ -1369,6 +1371,7 @@ export const featureFlagsToolset: ToolsetDefinition = { return { path: `/fme/api/v4/segment-definitions/${segmentName}/keys` }; }, operationPolicy: { risk: "medium_write", retryPolicy: "do_not_retry" }, + skipScopeBodyInjection: true, queryParams: { environment_id: "environment_id", replace: "replace" }, bodyBuilder: (input) => fmeSegmentDefinitionKeysBody(input, { requireNonEmpty: false }), responseExtractor: passthrough, @@ -1386,6 +1389,7 @@ export const featureFlagsToolset: ToolsetDefinition = { return { path: `/fme/api/v4/segment-definitions/${segmentName}/keys/remove` }; }, operationPolicy: { risk: "medium_write", retryPolicy: "do_not_retry" }, + skipScopeBodyInjection: true, queryParams: { environment_id: "environment_id" }, bodyBuilder: (input) => fmeSegmentDefinitionKeysBody(input, { requireNonEmpty: true }), responseExtractor: passthrough, diff --git a/tests/registry/fme-segment-remaining.test.ts b/tests/registry/fme-segment-remaining.test.ts index 8e66cad8..1d656a65 100644 --- a/tests/registry/fme-segment-remaining.test.ts +++ b/tests/registry/fme-segment-remaining.test.ts @@ -3,6 +3,7 @@ import type { Config } from "../../src/config.js"; import type { HarnessClient } from "../../src/client/harness-client.js"; import type { RequestOptions } from "../../src/client/types.js"; import { Registry } from "../../src/registry/index.js"; +import { featureFlagsToolset } from "../../src/registry/toolsets/feature-flags.js"; function makeConfig(overrides: Partial = {}): Config { return { @@ -161,6 +162,10 @@ describe("fme_segment_definition remaining key execute actions", () => { expect(request.path).toBe("/fme/api/v4/segment-definitions/seg1/keys"); expect(request.params).toMatchObject({ environment_id: "env1", replace: true }); expect(request.body).toEqual({ keys: ["a", "b"], comment: "c", title: "t" }); + const addKeys = featureFlagsToolset.resources + .find((r) => r.resourceType === "fme_segment_definition") + ?.executeActions?.add_keys; + expect(addKeys?.skipScopeBodyInjection).toBe(true); }); it("removes keys via POST keys/remove", async () => { From 5b8fd9a793969d74da7b36191d939d6fcb7f3914 Mon Sep 17 00:00:00 2001 From: Ravindra Singh Shekhawat Date: Mon, 17 Aug 2026 23:27:18 +0530 Subject: [PATCH 3/8] fix: omit type from native FME segment create bodies CreateSegmentRequest has no type field, and Jackson rejects unknown JSON. Keep local type validation when callers pass it. Co-authored-by: Cursor --- src/registry/toolsets/feature-flags.ts | 5 ++--- tests/registry/feature-flags.test.ts | 19 +++++++++---------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/registry/toolsets/feature-flags.ts b/src/registry/toolsets/feature-flags.ts index c5804694..40201338 100644 --- a/src/registry/toolsets/feature-flags.ts +++ b/src/registry/toolsets/feature-flags.ts @@ -103,7 +103,7 @@ const fmeSegmentCreateSchema: BodySchema = { description: "Create a new segment. name, trafficType, and type are required; description/tags/owners are optional.", fields: [ { name: "name", type: "string", required: true, description: "Segment name (must be unique within the project)" }, - { name: "type", type: "string", required: true, description: "Segment kind. Must be one of: \"standard\" | \"rule_based\" | \"large\"." }, + { name: "type", type: "string", required: false, description: "Segment kind for MCP callers (\"standard\" | \"rule_based\" | \"large\"). Not sent on the v4 create wire — CreateSegmentRequest has no type field." }, { name: "description", type: "string", required: false, description: "Optional description of the segment" }, { name: "trafficType", type: "string", required: true, description: "Traffic type name" }, { name: "tags", type: "array", required: false, description: "Each entry is {name: string}; bare strings are accepted and auto-wrapped", itemType: "object" }, @@ -1212,7 +1212,6 @@ export const featureFlagsToolset: ToolsetDefinition = { return { name: body?.name, trafficType, - type: body?.type, ...(body?.description !== undefined ? { description: body.description } : {}), ...(body?.tags !== undefined ? { tags: normalizeFmeTags(body.tags) } : {}), ...(body?.owners !== undefined ? { owners: body.owners } : {}), @@ -1220,7 +1219,7 @@ export const featureFlagsToolset: ToolsetDefinition = { }, responseExtractor: passthrough, bodySchema: fmeSegmentCreateSchema, - description: "Create a new segment. Body requires name + trafficType + type (\"standard\" | \"rule_based\" | \"large\"); optional description, tags, owners.", + description: "Create a new segment. Body requires name + trafficType; optional description, tags, owners. If type is passed (\"standard\" | \"rule_based\" | \"large\"), it is validated locally and omitted from the v4 wire body.", }, update: { method: "PATCH", diff --git a/tests/registry/feature-flags.test.ts b/tests/registry/feature-flags.test.ts index 7ed2d888..8e34eb4e 100644 --- a/tests/registry/feature-flags.test.ts +++ b/tests/registry/feature-flags.test.ts @@ -1032,7 +1032,7 @@ describe("fme_segment", () => { organization_identifier: "o1", project_identifier: "p1", }); - expect(req.body).toEqual({ name: "x", type: "standard", trafficType: "user", tags: [{ name: "a" }] }); + expect(req.body).toEqual({ name: "x", trafficType: "user", tags: [{ name: "a" }] }); }); it("create: rejects an invalid type value", async () => { @@ -1049,18 +1049,17 @@ describe("fme_segment", () => { expect(mockRequest).not.toHaveBeenCalled(); }); - it("create: missing type surfaces as a missing-required-field error", async () => { + it("create: missing type is allowed because v4 CreateSegmentRequest has no type field", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); - await expect( - registry.dispatch(client, "fme_segment", "create", { - org_id: "o1", - project_id: "p1", - body: { name: "x", trafficType: "user" }, - }), - ).rejects.toThrow(/type/i); - expect(mockRequest).not.toHaveBeenCalled(); + await registry.dispatch(client, "fme_segment", "create", { + org_id: "o1", + project_id: "p1", + body: { name: "x", trafficType: "user" }, + }); + + expect(firstRequest(mockRequest).body).toEqual({ name: "x", trafficType: "user" }); }); it("create: missing trafficType surfaces as a missing-required-field error", async () => { From bb9a82066e88bafc944f87d9be9d60b5d10a3867 Mon Sep 17 00:00:00 2001 From: Ravindra Singh Shekhawat Date: Mon, 17 Aug 2026 23:44:54 +0530 Subject: [PATCH 4/8] fix: align native segment create docs and empty-key replace gate Reviewers would see describe() still requiring type after the wire omit, and add_keys allowed empty keys without replace=true. README matrix now matches the new update and key execute actions so docs:check passes. Co-authored-by: Cursor --- README.md | 10 +++---- src/registry/toolsets/feature-flags.ts | 16 +++++++---- tests/registry/fme-segment-remaining.test.ts | 29 ++++++++++++++++++++ 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 39ac032c..35381767 100644 --- a/README.md +++ b/README.md @@ -1497,8 +1497,8 @@ Use `harness_execute(resource_type="pull_request", action="close", ...)` for an | `fme_identity` | | | x | x | | | | `fme_standard_segment` | x | x | | | | | | `fme_segment_keys` | x | | | x | | | -| `fme_segment` | x | x | x | | x | | -| `fme_segment_definition` | x | x | x | x | x | | +| `fme_segment` | x | x | x | x | x | | +| `fme_segment_definition` | x | x | x | x | x | `list_keys`, `add_keys`, `remove_keys` | **FME (Split.io) resources** — `fme_`* resources support **dual-mode scoping**: legacy calls pass `workspace_id` and hit the Split.io API (`api.split.io`); newer calls pass `org_id`+`project_id` together and hit Harness-native endpoints (standard `HARNESS_API_KEY`/`HARNESS_BASE_URL`, same auth as every other `harness_*` resource) instead. Passing both `workspace_id` and `org_id`/`project_id` on the same call, or mixing `org_id` with `project_id` alone, is an error — pick one mode per call. Every operation below is available in legacy mode, unchanged. Harness-native mode coverage is currently narrower: @@ -1513,9 +1513,9 @@ Use `harness_execute(resource_type="pull_request", action="close", ...)` for an - **`fme_traffic_type`** — `list` is not yet implemented. - **`fme_identity`** — `create`/`update` are not yet implemented if `org_id`+`project_id` are passed together; otherwise proceeds as a normal legacy call. - **`fme_standard_segment`** — (Deprecated — see `fme_segment`.) Harness-native mode is rejected on every operation (`list`/`get`) — use `fme_segment` instead; this resource supports only the legacy `workspace_id` contract. There is no `create` operation for this resource in either mode. -- **`fme_segment_keys`** — `list`/`update` are not yet implemented if `org_id`+`project_id` are passed together; otherwise proceeds as a normal legacy call. -- **`fme_segment`** — `list`/`get`/`create`/`delete` are wired to the real `/fme/api/v4/segments` endpoint (consolidates `fme_standard_segment`/`fme_rule_based_segment`); `create` body: `name`, `trafficType`, `type` (required — one of `standard`/`rule_based`/`large`), optional `description`/`tags`/`owners`. -- **`fme_segment_definition`** — Harness-native only (no legacy `workspace_id` support). `list`/`get`/`create`/`update`/`delete` are wired to `/fme/api/v4/segment-definitions`, per `Harness_Split/Main` PR #12644 (open, not yet merged as of this writing — paths may still change). `update` uses JSON Merge Patch on `description`, the only mutable field. There is no `enable`/`disable`/`change_request` action — the backend has no such endpoints for this unified resource. +- **`fme_segment_keys`** — `list`/`update` remain legacy (`workspace_id` or environment+segment without Harness-native org/project). Passing `org_id`+`project_id` is rejected; use `fme_segment_definition` execute `list_keys`/`add_keys`/`remove_keys` instead. +- **`fme_segment`** — `list`/`get`/`create`/`update`/`delete` wired to `/fme/api/v4/segments`. `create` body: `name`, `trafficType`, optional `description`/`tags`/`owners` (`CreateSegmentRequest` has no `type` field — if callers pass `type` it is validated locally and omitted on the wire). `update` is JSON Merge Patch on `description`/`tags`/`owners`. +- **`fme_segment_definition`** — Harness-native only. `list`/`get`/`create`/`update`/`delete` plus execute `list_keys`/`add_keys`/`remove_keys` on `/fme/api/v4/segment-definitions`. `update` uses JSON Merge Patch on `description`. Membership keys must be cleared before delete (400 `hasDependents` otherwise). There is no `enable`/`disable`/`change_request` action. In single-user/self-hosted mode, legacy-mode auth uses a Bearer token from `HARNESS_FME_API_KEY`, falling back to a non-placeholder `HARNESS_API_KEY`. `HARNESS_FME_API_KEY` may be a legacy Split admin key or an FME-entitled Harness PAT/SAT, but it is rejected in `multi-user` mode so shared deployments cannot override each session user's credential. Hosted OAuth/service-routing credentials for Harness platform APIs do not authenticate direct Split.io requests. `fme_feature_flag` supports full lifecycle management in legacy mode: create (requires `traffic_type_id`), list, get, update metadata, delete, and kill/restore/reallocate/archive/unarchive execute actions. Use `fme_traffic_type` to discover traffic type IDs, `fme_identity` to create/update identity attributes, and `fme_standard_segment` / `fme_segment_keys` to inspect standard segments and add member keys. `fme_rule_based_segment` provides CRUD for targeting segments, while `fme_rule_based_segment_definition` manages environment-specific segment rules with enable/disable and change request approval flows. diff --git a/src/registry/toolsets/feature-flags.ts b/src/registry/toolsets/feature-flags.ts index 40201338..deeefb9b 100644 --- a/src/registry/toolsets/feature-flags.ts +++ b/src/registry/toolsets/feature-flags.ts @@ -100,7 +100,7 @@ const fmeRbsCreateSchema: BodySchema = { }; const fmeSegmentCreateSchema: BodySchema = { - description: "Create a new segment. name, trafficType, and type are required; description/tags/owners are optional.", + description: "Create a new segment. name and trafficType are required; description/tags/owners are optional. type is optional MCP-only validation and is not sent on the v4 wire.", fields: [ { name: "name", type: "string", required: true, description: "Segment name (must be unique within the project)" }, { name: "type", type: "string", required: false, description: "Segment kind for MCP callers (\"standard\" | \"rule_based\" | \"large\"). Not sent on the v4 create wire — CreateSegmentRequest has no type field." }, @@ -147,6 +147,7 @@ function fmeSegmentKeysNativePointer(operation: string): never { function fmeSegmentDefinitionKeysBody(input: Record, opts: { requireNonEmpty: boolean }): Record { const body = input.body as Record | undefined; const keys = body?.keys; + const replace = input.replace === true || input.replace === "true"; if (!Array.isArray(keys) || (opts.requireNonEmpty && keys.length === 0)) { throw new Error( opts.requireNonEmpty @@ -154,6 +155,9 @@ function fmeSegmentDefinitionKeysBody(input: Record, opts: { re : "fme_segment_definition.add_keys requires body.keys (array; empty only when replace=true).", ); } + if (!opts.requireNonEmpty && keys.length === 0 && !replace) { + throw new Error("fme_segment_definition.add_keys requires body.keys (array; empty only when replace=true)."); + } return { keys, ...(body?.comment !== undefined ? { comment: body.comment } : {}), @@ -1147,7 +1151,7 @@ export const featureFlagsToolset: ToolsetDefinition = { resourceType: "fme_segment", displayName: "FME Segment", description: - "FME (Harness-native, org_id+project_id scoped). Unified segment type (standard, rule-based, and large). Supports list, get, create, update (JSON Merge Patch on description/tags/owners), and delete. create requires body.type (\"standard\" | \"rule_based\" | \"large\").", + "FME (Harness-native, org_id+project_id scoped). Unified segment type (standard, rule-based, and large). Supports list, get, create, update (JSON Merge Patch on description/tags/owners), and delete. create requires name + trafficType; optional type is validated locally if passed and omitted from the v4 wire body. Delete returns 400 hasDependents while environment definitions or flag targeting still reference the segment.", toolset: "feature-flags", scope: "project", scopeParams: FME_HARNESS_NATIVE_SCOPE_PARAMS, @@ -1190,7 +1194,8 @@ export const featureFlagsToolset: ToolsetDefinition = { }, operationPolicy: { risk: "destructive", retryPolicy: "do_not_retry" }, responseExtractor: passthrough, - description: "Delete a segment by name.", + description: + "Delete a segment by name. Returns 400 hasDependents if any environment definition or flag still references it — delete definitions (after clearing keys) first.", }, create: { method: "POST", @@ -1254,7 +1259,7 @@ export const featureFlagsToolset: ToolsetDefinition = { resourceType: "fme_segment_definition", displayName: "FME Segment Definition", description: - "Environment-specific definition of a segment (standard or rule-based) — description, lifecycle, and membership keys. Replaces fme_rule_based_segment_definition's role in Harness-native calls, generalized for all segment types. Harness-native only (org_id+project_id; no legacy workspace_id support). Supports list, get, create, update (description only, via JSON Merge Patch), delete, and execute list_keys/add_keys/remove_keys. There is no enable/disable/change_request action: the backend has no such endpoints for this unified resource; governance checks are surfaced inline in the create/update/delete responses instead.", + "Environment-specific definition of a segment (standard or rule-based) — description, lifecycle, and membership keys. Replaces fme_rule_based_segment_definition's role in Harness-native calls, generalized for all segment types. Harness-native only (org_id+project_id; no legacy workspace_id support). Supports list, get, create, update (description only, via JSON Merge Patch), delete, and execute list_keys/add_keys/remove_keys. Delete returns 400 hasDependents while keys remain. There is no enable/disable/change_request action: the backend has no such endpoints for this unified resource; governance checks are surfaced inline in the create/update/delete responses instead.", toolset: "feature-flags", scope: "project", scopeParams: FME_HARNESS_NATIVE_SCOPE_PARAMS, @@ -1341,7 +1346,8 @@ export const featureFlagsToolset: ToolsetDefinition = { operationPolicy: { risk: "destructive", retryPolicy: "do_not_retry" }, queryParams: { environment_id: "environment_id" }, responseExtractor: passthrough, - description: "Delete a segment definition from an environment.", + description: + "Delete a segment definition from an environment. Returns 400 hasDependents while membership keys remain — remove_keys (or add_keys with replace=true and empty keys) first.", }, }, executeActions: { diff --git a/tests/registry/fme-segment-remaining.test.ts b/tests/registry/fme-segment-remaining.test.ts index 1d656a65..0849755e 100644 --- a/tests/registry/fme-segment-remaining.test.ts +++ b/tests/registry/fme-segment-remaining.test.ts @@ -186,6 +186,35 @@ describe("fme_segment_definition remaining key execute actions", () => { expect(request.body).toEqual({ keys: ["a"], comment: "c", title: "t" }); }); + it("rejects empty add_keys unless replace is true", async () => { + const client = makeClient(); + + await expect( + registry.dispatchExecute(client, "fme_segment_definition", "add_keys", { + ...nativeScope, + segment_name: "seg1", + environment_id: "env1", + body: { keys: [] }, + }), + ).rejects.toThrow(/empty only when replace=true/); + }); + + it("allows empty add_keys when replace is true", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatchExecute(client, "fme_segment_definition", "add_keys", { + ...nativeScope, + segment_name: "seg1", + environment_id: "env1", + replace: true, + body: { keys: [] }, + }); + + expect(firstRequest(mockRequest).params).toMatchObject({ replace: true }); + expect(firstRequest(mockRequest).body).toEqual({ keys: [] }); + }); + it("requires keys array on remove_keys", async () => { const client = makeClient(); From 602286c3fa27189c9fc339cc0c8908ab20feda3b Mon Sep 17 00:00:00 2001 From: Ravindra Singh Shekhawat Date: Wed, 19 Aug 2026 10:11:49 +0530 Subject: [PATCH 5/8] fix: align fme_segment list and create with public v4 kinds Java now requires segment_type on list and segmentType on create; omitting kind or sending JSON type 400s. Co-authored-by: Cursor --- README.md | 2 +- src/registry/toolsets/feature-flags.ts | 85 +++++++++++++--- tests/registry/feature-flags.test.ts | 133 +++++++++++++++++++++++-- 3 files changed, 198 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 35381767..101a7c3c 100644 --- a/README.md +++ b/README.md @@ -1514,7 +1514,7 @@ Use `harness_execute(resource_type="pull_request", action="close", ...)` for an - **`fme_identity`** — `create`/`update` are not yet implemented if `org_id`+`project_id` are passed together; otherwise proceeds as a normal legacy call. - **`fme_standard_segment`** — (Deprecated — see `fme_segment`.) Harness-native mode is rejected on every operation (`list`/`get`) — use `fme_segment` instead; this resource supports only the legacy `workspace_id` contract. There is no `create` operation for this resource in either mode. - **`fme_segment_keys`** — `list`/`update` remain legacy (`workspace_id` or environment+segment without Harness-native org/project). Passing `org_id`+`project_id` is rejected; use `fme_segment_definition` execute `list_keys`/`add_keys`/`remove_keys` instead. -- **`fme_segment`** — `list`/`get`/`create`/`update`/`delete` wired to `/fme/api/v4/segments`. `create` body: `name`, `trafficType`, optional `description`/`tags`/`owners` (`CreateSegmentRequest` has no `type` field — if callers pass `type` it is validated locally and omitted on the wire). `update` is JSON Merge Patch on `description`/`tags`/`owners`. +- **`fme_segment`** — `list`/`get`/`create`/`update`/`delete` wired to `/fme/api/v4/segments`. `list` requires filter `segment_type` (`STANDARD` | `LARGE` | `RULE_BASED`; one kind per call) plus optional `status`/`offset`/`limit`. `create` body: `name`, `trafficType`, required JSON `segmentType` (same enum); optional `description`/`tags`/`owners`. MCP alias `type` (`standard` | `large` | `rule_based`) maps to `segmentType` for one release and is never sent on the wire. `update` is JSON Merge Patch (`application/merge-patch+json`) on `description`/`tags`/`owners`. GET/PATCH/DELETE have no MCP kind gate; the backend STANDARD-store lookup 404s for LARGE/RULE_BASED names. - **`fme_segment_definition`** — Harness-native only. `list`/`get`/`create`/`update`/`delete` plus execute `list_keys`/`add_keys`/`remove_keys` on `/fme/api/v4/segment-definitions`. `update` uses JSON Merge Patch on `description`. Membership keys must be cleared before delete (400 `hasDependents` otherwise). There is no `enable`/`disable`/`change_request` action. In single-user/self-hosted mode, legacy-mode auth uses a Bearer token from `HARNESS_FME_API_KEY`, falling back to a non-placeholder `HARNESS_API_KEY`. `HARNESS_FME_API_KEY` may be a legacy Split admin key or an FME-entitled Harness PAT/SAT, but it is rejected in `multi-user` mode so shared deployments cannot override each session user's credential. Hosted OAuth/service-routing credentials for Harness platform APIs do not authenticate direct Split.io requests. `fme_feature_flag` supports full lifecycle management in legacy mode: create (requires `traffic_type_id`), list, get, update metadata, delete, and kill/restore/reallocate/archive/unarchive execute actions. Use `fme_traffic_type` to discover traffic type IDs, `fme_identity` to create/update identity attributes, and `fme_standard_segment` / `fme_segment_keys` to inspect standard segments and add member keys. `fme_rule_based_segment` provides CRUD for targeting segments, while `fme_rule_based_segment_definition` manages environment-specific segment rules with enable/disable and change request approval flows. diff --git a/src/registry/toolsets/feature-flags.ts b/src/registry/toolsets/feature-flags.ts index deeefb9b..037808ea 100644 --- a/src/registry/toolsets/feature-flags.ts +++ b/src/registry/toolsets/feature-flags.ts @@ -25,7 +25,59 @@ function normalizeFmeTags(tags: unknown): unknown { return Array.isArray(tags) ? tags.map((t) => (typeof t === "string" ? { name: t } : t)) : tags; } -const FME_SEGMENT_TYPES = ["standard", "rule_based", "large"] as const; +const FME_SEGMENT_KINDS = ["STANDARD", "LARGE", "RULE_BASED"] as const; +type FmeSegmentKind = (typeof FME_SEGMENT_KINDS)[number]; + +/** One-release MCP-only alias: old lowercase `type` → wire `segmentType`. Never sent as JSON `type`. */ +const FME_SEGMENT_TYPE_ALIASES: Record = { + standard: "STANDARD", + large: "LARGE", + rule_based: "RULE_BASED", +}; + +function isFmeSegmentKind(value: unknown): value is FmeSegmentKind { + return typeof value === "string" && (FME_SEGMENT_KINDS as readonly string[]).includes(value); +} + +function resolveFmeCreateSegmentType(body: Record | undefined): FmeSegmentKind { + const primary = body?.segmentType; + const alias = body?.type; + + let fromPrimary: FmeSegmentKind | undefined; + if (primary !== undefined) { + if (!isFmeSegmentKind(primary)) { + throw new Error( + `fme_segment.create: invalid segmentType '${String(primary)}'. Must be one of: ${FME_SEGMENT_KINDS.join(", ")}.`, + ); + } + fromPrimary = primary; + } + + let fromAlias: FmeSegmentKind | undefined; + if (alias !== undefined) { + const mapped = typeof alias === "string" ? FME_SEGMENT_TYPE_ALIASES[alias] : undefined; + if (mapped === undefined) { + throw new Error( + `fme_segment.create: invalid type '${String(alias)}'. Must be one of: ${Object.keys(FME_SEGMENT_TYPE_ALIASES).join(", ")}.`, + ); + } + fromAlias = mapped; + } + + if (fromPrimary !== undefined && fromAlias !== undefined && fromPrimary !== fromAlias) { + throw new Error( + `fme_segment.create: segmentType '${fromPrimary}' conflicts with type '${String(alias)}'.`, + ); + } + + const kind = fromPrimary ?? fromAlias; + if (kind === undefined) { + throw new Error( + "fme_segment.create: segmentType is required (STANDARD | LARGE | RULE_BASED), or pass alias type: standard | large | rule_based.", + ); + } + return kind; +} const fmeFeatureFlagUpdateSchema: BodySchema = { description: "Partial update for an FME feature flag's metadata. Provide the fields you want to change. Legacy mode (workspace_id): converted to JSON Patch (RFC 6902) automatically — description/tags/rolloutStatus only. Harness-native mode (org_id+project_id): sent as JSON Merge Patch (RFC 7396) — description/tags/owners/rolloutStatus; set description/tags/owners to null (or [] for tags/owners) to clear them, omit a field to leave it unchanged.", @@ -100,10 +152,11 @@ const fmeRbsCreateSchema: BodySchema = { }; const fmeSegmentCreateSchema: BodySchema = { - description: "Create a new segment. name and trafficType are required; description/tags/owners are optional. type is optional MCP-only validation and is not sent on the v4 wire.", + description: "Create a new segment. name, trafficType, and segmentType are required; description/tags/owners are optional. type is an optional one-release MCP alias for segmentType and is never sent on the v4 wire.", fields: [ { name: "name", type: "string", required: true, description: "Segment name (must be unique within the project)" }, - { name: "type", type: "string", required: false, description: "Segment kind for MCP callers (\"standard\" | \"rule_based\" | \"large\"). Not sent on the v4 create wire — CreateSegmentRequest has no type field." }, + { name: "segmentType", type: "string", required: true, description: "Required wire kind: STANDARD | LARGE | RULE_BASED. Do not lowercase. If omitted, alias type (standard | large | rule_based) is mapped to this field." }, + { name: "type", type: "string", required: false, description: "Optional one-release MCP alias only (standard | large | rule_based). Mapped to segmentType; never sent as JSON type. If both are present they must agree." }, { name: "description", type: "string", required: false, description: "Optional description of the segment" }, { name: "trafficType", type: "string", required: true, description: "Traffic type name" }, { name: "tags", type: "array", required: false, description: "Each entry is {name: string}; bare strings are accepted and auto-wrapped", itemType: "object" }, @@ -1151,11 +1204,17 @@ export const featureFlagsToolset: ToolsetDefinition = { resourceType: "fme_segment", displayName: "FME Segment", description: - "FME (Harness-native, org_id+project_id scoped). Unified segment type (standard, rule-based, and large). Supports list, get, create, update (JSON Merge Patch on description/tags/owners), and delete. create requires name + trafficType; optional type is validated locally if passed and omitted from the v4 wire body. Delete returns 400 hasDependents while environment definitions or flag targeting still reference the segment.", + "FME (Harness-native, org_id+project_id scoped). Unified segment resource (STANDARD, LARGE, RULE_BASED). Supports list (required segment_type filter — one kind per call), get, create (required JSON segmentType), update (JSON Merge Patch on description/tags/owners), and delete. GET/PATCH/DELETE have no MCP kind gate; the backend looks up STANDARD store names only, so LARGE/RULE_BASED names 404. Delete returns 400 hasDependents while environment definitions or flag targeting still reference the segment.", toolset: "feature-flags", scope: "project", scopeParams: FME_HARNESS_NATIVE_SCOPE_PARAMS, identifierFields: ["segment_name"], + listFilterFields: [ + { name: "segment_type", description: "Required. One kind per list call: STANDARD | LARGE | RULE_BASED (case is canonicalized; unmatched values pass through to the API).", enum: ["STANDARD", "LARGE", "RULE_BASED"], required: true }, + { name: "status", description: "Optional. Filter by segment status (omit for Java default ACTIVE). One value only.", enum: ["ACTIVE", "ARCHIVED"] }, + { name: "offset", description: "Pagination offset", type: "number" }, + { name: "limit", description: "Page size (max 100, default 100)", type: "number" }, + ], operations: { list: { method: "GET", @@ -1165,8 +1224,9 @@ export const featureFlagsToolset: ToolsetDefinition = { return { path: "/fme/api/v4/segments" }; }, operationPolicy: { risk: "read", retryPolicy: "safe" }, + queryParams: { segment_type: "segment_type", status: "status", offset: "offset", limit: "limit" }, responseExtractor: passthrough, - description: "List all segments (standard and rule-based) in project.", + description: "List segments of one required kind (filters.segment_type = STANDARD | LARGE | RULE_BASED). Optional status (ACTIVE | ARCHIVED), offset, and limit. Does not list mixed kinds in one call.", }, get: { method: "GET", @@ -1180,7 +1240,7 @@ export const featureFlagsToolset: ToolsetDefinition = { }, operationPolicy: { risk: "read", retryPolicy: "safe" }, responseExtractor: passthrough, - description: "Get single segment by name.", + description: "Get a segment by name. MCP does not gate on kind; the backend STANDARD-store lookup 404s for LARGE/RULE_BASED names.", }, delete: { method: "DELETE", @@ -1195,7 +1255,7 @@ export const featureFlagsToolset: ToolsetDefinition = { operationPolicy: { risk: "destructive", retryPolicy: "do_not_retry" }, responseExtractor: passthrough, description: - "Delete a segment by name. Returns 400 hasDependents if any environment definition or flag still references it — delete definitions (after clearing keys) first.", + "Delete a segment by name. MCP does not gate on kind; the backend STANDARD-store lookup 404s for LARGE/RULE_BASED names. Returns 400 hasDependents if any environment definition or flag still references it — delete definitions (after clearing keys) first.", }, create: { method: "POST", @@ -1209,14 +1269,11 @@ export const featureFlagsToolset: ToolsetDefinition = { bodyBuilder: (input) => { const body = input.body as Record | undefined; const trafficType = (body?.trafficType ?? body?.traffic_type) as string | undefined; - if (body?.type !== undefined && !FME_SEGMENT_TYPES.includes(body.type as (typeof FME_SEGMENT_TYPES)[number])) { - throw new Error( - `fme_segment.create: invalid type '${body.type}'. Must be one of: ${FME_SEGMENT_TYPES.join(", ")}.`, - ); - } + const segmentType = resolveFmeCreateSegmentType(body); return { name: body?.name, trafficType, + segmentType, ...(body?.description !== undefined ? { description: body.description } : {}), ...(body?.tags !== undefined ? { tags: normalizeFmeTags(body.tags) } : {}), ...(body?.owners !== undefined ? { owners: body.owners } : {}), @@ -1224,7 +1281,7 @@ export const featureFlagsToolset: ToolsetDefinition = { }, responseExtractor: passthrough, bodySchema: fmeSegmentCreateSchema, - description: "Create a new segment. Body requires name + trafficType; optional description, tags, owners. If type is passed (\"standard\" | \"rule_based\" | \"large\"), it is validated locally and omitted from the v4 wire body.", + description: "Create a new segment. Body requires name + trafficType + segmentType (STANDARD | LARGE | RULE_BASED). Optional description, tags, owners. Alias type (standard | large | rule_based) maps to segmentType for one release and is never sent on the wire.", }, update: { method: "PATCH", @@ -1250,7 +1307,7 @@ export const featureFlagsToolset: ToolsetDefinition = { responseExtractor: passthrough, bodySchema: fmeSegmentUpdateSchema, description: - "Update a segment's description, tags, and/or owners via JSON Merge Patch (RFC 7396). Harness-native only (org_id+project_id). Omit a field to leave it unchanged; set description/tags/owners to null (or [] for tags/owners) to clear.", + "Update a segment's description, tags, and/or owners via JSON Merge Patch (RFC 7396). Harness-native only (org_id+project_id). MCP does not gate on kind; the backend STANDARD-store lookup 404s for LARGE/RULE_BASED names. Omit a field to leave it unchanged; set description/tags/owners to null (or [] for tags/owners) to clear.", }, }, }, diff --git a/tests/registry/feature-flags.test.ts b/tests/registry/feature-flags.test.ts index 8e34eb4e..cf12deac 100644 --- a/tests/registry/feature-flags.test.ts +++ b/tests/registry/feature-flags.test.ts @@ -921,13 +921,14 @@ describe("fme_segment", () => { registry = new Registry(makeConfig()); }); - it("list: routes /fme/api/v4/segments org_id+project_id", async () => { + it("list: routes /fme/api/v4/segments org_id+project_id with segment_type", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); await registry.dispatch(client, "fme_segment", "list", { org_id: "o1", project_id: "p1", + segment_type: "STANDARD", }); const req = firstRequest(mockRequest); @@ -937,11 +938,52 @@ describe("fme_segment", () => { account_id: "test-account", organization_identifier: "o1", project_identifier: "p1", + segment_type: "STANDARD", }); expect(req.params?.orgIdentifier).toBeUndefined(); expect(req.params?.projectIdentifier).toBeUndefined(); }); + it("list: canonicalizes lowercase segment_type to STANDARD", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_segment", "list", { + org_id: "o1", + project_id: "p1", + segment_type: "standard", + }); + + expect(firstRequest(mockRequest).params).toMatchObject({ segment_type: "STANDARD" }); + }); + + it("list: passes unmatched segment_type through without a local throw", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_segment", "list", { + org_id: "o1", + project_id: "p1", + segment_type: "not_a_kind", + }); + + expect(mockRequest).toHaveBeenCalled(); + expect(firstRequest(mockRequest).params).toMatchObject({ segment_type: "not_a_kind" }); + }); + + it("list: throws when segment_type is missing and does not call the API", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_segment", "list", { + org_id: "o1", + project_id: "p1", + }), + ).rejects.toThrow(/Missing required filter.*segment_type/); + expect(mockRequest).not.toHaveBeenCalled(); + }); + it("list: throws when org_id missing", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); @@ -949,6 +991,7 @@ describe("fme_segment", () => { await expect( registry.dispatch(client, "fme_segment", "list", { project_id: "p1", + segment_type: "STANDARD", }), ).rejects.toThrow("fme_segment: org_id and project_id are required (account is taken from config)."); expect(mockRequest).not.toHaveBeenCalled(); @@ -1032,7 +1075,33 @@ describe("fme_segment", () => { organization_identifier: "o1", project_identifier: "p1", }); - expect(req.body).toEqual({ name: "x", trafficType: "user", tags: [{ name: "a" }] }); + expect(req.body).toEqual({ + name: "x", + trafficType: "user", + segmentType: "STANDARD", + tags: [{ name: "a" }], + }); + expect(req.body).not.toHaveProperty("type"); + }); + + it("create: primary path sends segmentType STANDARD and omits type", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_segment", "create", { + org_id: "o1", + project_id: "p1", + body: { name: "x", trafficType: "user", segmentType: "STANDARD", tags: ["a"] }, + }); + + const body = firstRequest(mockRequest).body as Record; + expect(body).toEqual({ + name: "x", + trafficType: "user", + segmentType: "STANDARD", + tags: [{ name: "a" }], + }); + expect(body).not.toHaveProperty("type"); }); it("create: rejects an invalid type value", async () => { @@ -1049,17 +1118,61 @@ describe("fme_segment", () => { expect(mockRequest).not.toHaveBeenCalled(); }); - it("create: missing type is allowed because v4 CreateSegmentRequest has no type field", async () => { + it("create: rejects an invalid segmentType value without HTTP", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_segment", "create", { + org_id: "o1", + project_id: "p1", + body: { name: "x", trafficType: "user", segmentType: "standard" }, + }), + ).rejects.toThrow(/invalid segmentType 'standard'/i); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it("create: missing both segmentType and type throws before HTTP", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_segment", "create", { + org_id: "o1", + project_id: "p1", + body: { name: "x", trafficType: "user" }, + }), + ).rejects.toThrow(/segmentType/i); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it("create: both present with the same kind is allowed", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); await registry.dispatch(client, "fme_segment", "create", { org_id: "o1", project_id: "p1", - body: { name: "x", trafficType: "user" }, + body: { name: "x", trafficType: "user", segmentType: "STANDARD", type: "standard" }, }); - expect(firstRequest(mockRequest).body).toEqual({ name: "x", trafficType: "user" }); + const body = firstRequest(mockRequest).body as Record; + expect(body).toEqual({ name: "x", trafficType: "user", segmentType: "STANDARD" }); + expect(body).not.toHaveProperty("type"); + }); + + it("create: both present with conflicting kinds throws without HTTP", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_segment", "create", { + org_id: "o1", + project_id: "p1", + body: { name: "x", trafficType: "user", segmentType: "LARGE", type: "standard" }, + }), + ).rejects.toThrow(/conflict/i); + expect(mockRequest).not.toHaveBeenCalled(); }); it("create: missing trafficType surfaces as a missing-required-field error", async () => { @@ -1067,7 +1180,11 @@ describe("fme_segment", () => { const client = makeClient(mockRequest); await expect( - registry.dispatch(client, "fme_segment", "create", { org_id: "o1", project_id: "p1", body: { name: "x" } }), + registry.dispatch(client, "fme_segment", "create", { + org_id: "o1", + project_id: "p1", + body: { name: "x", segmentType: "STANDARD" }, + }), ).rejects.toThrow(/trafficType/i); expect(mockRequest).not.toHaveBeenCalled(); }); @@ -1077,7 +1194,9 @@ describe("fme_segment", () => { const client = makeClient(mockRequest); await expect( - registry.dispatch(client, "fme_segment", "create", { body: { name: "x", trafficType: "user" } }), + registry.dispatch(client, "fme_segment", "create", { + body: { name: "x", trafficType: "user", segmentType: "STANDARD" }, + }), ).rejects.toThrow(/org_id and project_id are required/i); expect(mockRequest).not.toHaveBeenCalled(); }); From 25b4b9fb27b68de2003839aab30f37d611a33ab2 Mon Sep 17 00:00:00 2001 From: Ravindra Singh Shekhawat Date: Wed, 19 Aug 2026 12:12:26 +0530 Subject: [PATCH 6/8] fix: restore #806 native rejects for legacy segment resources Keep deprecated standard/rule-based segments native-rejected and segment_keys as not-yet-implemented, matching the first dual-mode PR. New fme_segment ops stay org/project-only and reject workspace_id. Co-authored-by: Cursor --- README.md | 8 +++---- src/registry/scope-utils.ts | 14 ++++++++---- src/registry/toolsets/feature-flags.ts | 20 ++++++++-------- tests/registry/feature-flags.test.ts | 10 ++++---- tests/registry/fme-segment-remaining.test.ts | 24 ++++++++++++-------- 5 files changed, 44 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 101a7c3c..4f91d1c9 100644 --- a/README.md +++ b/README.md @@ -1508,12 +1508,12 @@ Use `harness_execute(resource_type="pull_request", action="close", ...)` for an - **`fme_feature_flag`** — dual-mode, both branches fully wired. Harness-native (`org_id`+`project_id`): `list`/`get`/`create`/`delete` hit `/fme/api/v4/feature-flags` (body for `create`: `name`, `trafficType`, optional `description`/`tags`/`owners`, per `CreateFeatureFlagRequest`); `update` sends a merge-patch to `/fme/api/v4/feature-flags/{name}`; `archive`/`unarchive` hit `/fme/api/v4/feature-flags/{name}/archive|unarchive` (optional `comment` only — no `title`, per `ArchiveUnarchiveRequest`); `kill`/`restore`/`reallocate` hit `/fme/api/v4/feature-flag-definitions/{name}/kill|restore|reallocate` with `environment_id` as a query param (optional `comment`/`title`, per `FeatureFlagDefinitionActionRequest`). - **`fme_feature_flag_definition`** — `get`/`create`/`update` are wired to the real `/fme/api/v4/feature-flag-definitions` endpoint. Body shape is identical to legacy mode (`treatments`, `defaultTreatment`, `defaultRule`, optional `rules`/`baselineTreatment`/`trafficAllocation`/`comment`), plus an optional `title` field available only in Harness-native mode. `environment_id` is passed as a query param (not a path segment, unlike legacy mode). - **`fme_rollout_status`** — `list` is not yet implemented. -- **`fme_rule_based_segment`** — (Deprecated — see `fme_segment`.) Harness-native mode is rejected on every operation (`list`/`get`/`create`/`delete`) — use `fme_segment` instead; this resource supports only the legacy `workspace_id` contract. -- **`fme_rule_based_segment_definition`** — (Deprecated — see `fme_segment_definition`.) Harness-native mode is rejected on every operation/action (`list`/`update`/`enable`/`disable`/`change_request`) — use `fme_segment_definition` instead (no `enable`/`disable`/`change_request` equivalent there); this resource supports only the legacy `workspace_id`/`environment_id` contract. +- **`fme_rule_based_segment`** — deprecated. Legacy `workspace_id` still hits Split v2. Harness-native (`org_id`+`project_id`) is rejected — use `fme_segment`. +- **`fme_rule_based_segment_definition`** — Harness-native mode is rejected (`list`/`update`/`enable`/`disable`/`change_request`). v4 has no enable/disable/change_request; environment definitions and keys live on `fme_segment_definition`. - **`fme_traffic_type`** — `list` is not yet implemented. - **`fme_identity`** — `create`/`update` are not yet implemented if `org_id`+`project_id` are passed together; otherwise proceeds as a normal legacy call. -- **`fme_standard_segment`** — (Deprecated — see `fme_segment`.) Harness-native mode is rejected on every operation (`list`/`get`) — use `fme_segment` instead; this resource supports only the legacy `workspace_id` contract. There is no `create` operation for this resource in either mode. -- **`fme_segment_keys`** — `list`/`update` remain legacy (`workspace_id` or environment+segment without Harness-native org/project). Passing `org_id`+`project_id` is rejected; use `fme_segment_definition` execute `list_keys`/`add_keys`/`remove_keys` instead. +- **`fme_standard_segment`** — deprecated. Legacy `workspace_id` still hits Split v2. Harness-native is rejected — use `fme_segment`. +- **`fme_segment_keys`** — `list`/`update` are not yet implemented if `org_id`+`project_id` are passed together; otherwise proceeds as a normal legacy call. Harness-native keys live on `fme_segment_definition` execute `list_keys`/`add_keys`/`remove_keys`. - **`fme_segment`** — `list`/`get`/`create`/`update`/`delete` wired to `/fme/api/v4/segments`. `list` requires filter `segment_type` (`STANDARD` | `LARGE` | `RULE_BASED`; one kind per call) plus optional `status`/`offset`/`limit`. `create` body: `name`, `trafficType`, required JSON `segmentType` (same enum); optional `description`/`tags`/`owners`. MCP alias `type` (`standard` | `large` | `rule_based`) maps to `segmentType` for one release and is never sent on the wire. `update` is JSON Merge Patch (`application/merge-patch+json`) on `description`/`tags`/`owners`. GET/PATCH/DELETE have no MCP kind gate; the backend STANDARD-store lookup 404s for LARGE/RULE_BASED names. - **`fme_segment_definition`** — Harness-native only. `list`/`get`/`create`/`update`/`delete` plus execute `list_keys`/`add_keys`/`remove_keys` on `/fme/api/v4/segment-definitions`. `update` uses JSON Merge Patch on `description`. Membership keys must be cleared before delete (400 `hasDependents` otherwise). There is no `enable`/`disable`/`change_request` action. diff --git a/src/registry/scope-utils.ts b/src/registry/scope-utils.ts index 33575175..dbeb4c33 100644 --- a/src/registry/scope-utils.ts +++ b/src/registry/scope-utils.ts @@ -119,12 +119,18 @@ export function isFmeHarnessNativeSelected(input: Record, resou } /** - * Guards Harness-native-only resources (e.g. fme_segment) that have no legacy - * Split.io fallback. Unlike `resolveFmeDualMode`, missing org_id/project_id here - * must throw rather than silently falling back to config.HARNESS_ORG/HARNESS_PROJECT — - * a stray global default must never leak into an FME-adjacent call. + * Guards Harness-native-only operations that have no legacy Split.io fallback. + * Rejects workspace_id (including mixed with org/project) so new ops cannot + * dual-route. Unlike `resolveFmeDualMode`, missing org_id/project_id must throw + * rather than falling back to config.HARNESS_ORG/HARNESS_PROJECT, and the error + * must not offer workspace_id as an alternative. */ export function requireHarnessNativeSegmentScope(input: Record, resourceType: string): void { + if (input.workspace_id) { + throw new Error( + `${resourceType}: Harness-native (org_id/project_id) only — pass org_id+project_id instead of workspace_id.`, + ); + } if (!input.org_id || !input.project_id) { throw new Error(`${resourceType}: org_id and project_id are required (account is taken from config).`); } diff --git a/src/registry/toolsets/feature-flags.ts b/src/registry/toolsets/feature-flags.ts index 037808ea..a03bf77e 100644 --- a/src/registry/toolsets/feature-flags.ts +++ b/src/registry/toolsets/feature-flags.ts @@ -191,12 +191,6 @@ const fmeSegmentDefinitionKeysRemoveSchema: BodySchema = { ], }; -function fmeSegmentKeysNativePointer(operation: string): never { - throw new Error( - `fme_segment_keys.${operation}: Harness-native membership uses fme_segment_definition execute actions list_keys, add_keys, and remove_keys — do not pass org_id+project_id on fme_segment_keys.`, - ); -} - function fmeSegmentDefinitionKeysBody(input: Record, opts: { requireNonEmpty: boolean }): Record { const body = input.body as Record | undefined; const keys = body?.keys; @@ -871,7 +865,7 @@ export const featureFlagsToolset: ToolsetDefinition = { const mode = resolveFmeDualMode(input, "fme_rule_based_segment"); if (mode.mode === "harness_native") { throw new Error( - "fme_rule_based_segment.create: Harness-native (org_id/project_id) mode is not supported on this deprecated resource — use fme_segment instead (create is not yet implemented there either).", + "fme_rule_based_segment.create: Harness-native (org_id/project_id) mode is not supported on this deprecated resource — use fme_segment instead.", ); } return { path: `/internal/api/v2/rule-based-segments/ws/${encodeURIComponent(mode.workspaceId)}/trafficTypes/${encodeURIComponent(requireFmeIdentifier(input, "traffic_type_id", "fme_rule_based_segment"))}` }; @@ -1465,7 +1459,7 @@ export const featureFlagsToolset: ToolsetDefinition = { resourceType: "fme_segment_keys", displayName: "FME Segment Keys", description: - "Membership keys (members) of a standard segment. List keys with pagination, or update to add members. Removal is not supported by this endpoint. Limit: 10,000 keys per request, 100,000 per segment total. Legacy workspace-style calls use environment_id+segment_name without org_id/project_id. Harness-native membership is not on this resource — use fme_segment_definition execute actions list_keys, add_keys, and remove_keys.", + "Membership keys (members) of a standard segment. List keys with pagination, or update to add members. Removal is not supported by this endpoint. Limit: 10,000 keys per request, 100,000 per segment total. For Harness-native key management use fme_segment_definition execute list_keys/add_keys/remove_keys.", toolset: "feature-flags", scope: "account", scopeOptional: true, @@ -1475,6 +1469,8 @@ export const featureFlagsToolset: ToolsetDefinition = { { name: "environment_id", description: "Environment ID (get from fme_environment)", required: true }, { name: "segment_name", description: "Segment name", required: true }, { name: "offset", description: "Pagination offset", type: "number" }, + { name: "org_id", description: "Optional — pass together with project_id to select the (not yet implemented) Harness-native mode instead of the current contract." }, + { name: "project_id", description: "Optional — pass together with org_id to select the (not yet implemented) Harness-native mode instead of the current contract." }, ], operations: { list: { @@ -1482,7 +1478,9 @@ export const featureFlagsToolset: ToolsetDefinition = { path: "/internal/api/v2/segments/{environmentId}/{segmentName}/keys", routeResolver: (input) => { if (isFmeHarnessNativeSelected(input, "fme_segment_keys.list")) { - fmeSegmentKeysNativePointer("list"); + throw new Error( + "fme_segment_keys.list: Harness-native (org_id/project_id) mode not yet implemented for this operation — pass environment_id/segment_name (current contract) instead.", + ); } const environmentId = encodeURIComponent(requireFmeIdentifier(input, "environment_id", "fme_segment_keys")); const segmentName = encodeURIComponent(requireFmeIdentifier(input, "segment_name", "fme_segment_keys")); @@ -1502,7 +1500,9 @@ export const featureFlagsToolset: ToolsetDefinition = { path: "/internal/api/v2/segments/{environmentId}/{segmentName}/upload", routeResolver: (input) => { if (isFmeHarnessNativeSelected(input, "fme_segment_keys.update")) { - fmeSegmentKeysNativePointer("update"); + throw new Error( + "fme_segment_keys.update: Harness-native (org_id/project_id) mode not yet implemented for this operation — pass environment_id/segment_name (current contract) instead.", + ); } const environmentId = encodeURIComponent(requireFmeIdentifier(input, "environment_id", "fme_segment_keys")); const segmentName = encodeURIComponent(requireFmeIdentifier(input, "segment_name", "fme_segment_keys")); diff --git a/tests/registry/feature-flags.test.ts b/tests/registry/feature-flags.test.ts index cf12deac..5d07d7ff 100644 --- a/tests/registry/feature-flags.test.ts +++ b/tests/registry/feature-flags.test.ts @@ -1396,7 +1396,7 @@ describe("fme_segment_keys permissive mode-selector", () => { expect(firstRequest(mockRequest).path).toBe("/internal/api/v2/segments/env-prod/beta_users/keys"); }); - it("org_id+project_id points list at fme_segment_definition execute actions", async () => { + it("org_id+project_id throws not-yet-implemented for list", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); @@ -1407,7 +1407,8 @@ describe("fme_segment_keys permissive mode-selector", () => { org_id: "o1", project_id: "p1", }), - ).rejects.toThrow("fme_segment_definition execute actions list_keys, add_keys, and remove_keys"); + ).rejects.toThrow(/not yet implemented/i); + expect(mockRequest).not.toHaveBeenCalled(); }); it("legacy mode still works for update", async () => { @@ -1424,7 +1425,7 @@ describe("fme_segment_keys permissive mode-selector", () => { expect(firstRequest(mockRequest).path).toBe("/internal/api/v2/segments/env-prod/beta_users/upload"); }); - it("org_id+project_id points update at fme_segment_definition execute actions", async () => { + it("org_id+project_id throws not-yet-implemented for update", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); @@ -1436,7 +1437,8 @@ describe("fme_segment_keys permissive mode-selector", () => { project_id: "p1", body: { add: ["user-1"] }, }), - ).rejects.toThrow("fme_segment_definition execute actions list_keys, add_keys, and remove_keys"); + ).rejects.toThrow(/not yet implemented/i); + expect(mockRequest).not.toHaveBeenCalled(); }); }); diff --git a/tests/registry/fme-segment-remaining.test.ts b/tests/registry/fme-segment-remaining.test.ts index 0849755e..e47b12f0 100644 --- a/tests/registry/fme-segment-remaining.test.ts +++ b/tests/registry/fme-segment-remaining.test.ts @@ -51,8 +51,6 @@ function firstRequest(mockRequest: ReturnType): RequestOptions { } const nativeScope = { org_id: "o1", project_id: "p1" }; -const KEYS_POINTER = - "fme_segment_definition execute actions list_keys, add_keys, and remove_keys"; describe("fme_segment remaining native update", () => { let registry: Registry; @@ -109,7 +107,9 @@ describe("fme_segment remaining native update", () => { segment_name: "seg1", body: { description: "x" }, }), - ).rejects.toThrow("fme_segment: org_id and project_id are required (account is taken from config)."); + ).rejects.toThrow( + "fme_segment: Harness-native (org_id/project_id) only — pass org_id+project_id instead of workspace_id.", + ); }); }); @@ -229,15 +229,16 @@ describe("fme_segment_definition remaining key execute actions", () => { }); }); -describe("fme_segment_keys native pointer", () => { +describe("fme_segment_keys native is not yet implemented", () => { let registry: Registry; beforeEach(() => { registry = new Registry(makeConfig()); }); - it("points native list at segment definition execute actions", async () => { - const client = makeClient(); + it("list throws not-yet-implemented", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); await expect( registry.dispatch(client, "fme_segment_keys", "list", { @@ -245,11 +246,13 @@ describe("fme_segment_keys native pointer", () => { environment_id: "env1", segment_name: "seg1", }), - ).rejects.toThrow(KEYS_POINTER); + ).rejects.toThrow(/not yet implemented/i); + expect(mockRequest).not.toHaveBeenCalled(); }); - it("points native update at segment definition execute actions", async () => { - const client = makeClient(); + it("update throws not-yet-implemented", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); await expect( registry.dispatch(client, "fme_segment_keys", "update", { @@ -258,6 +261,7 @@ describe("fme_segment_keys native pointer", () => { segment_name: "seg1", body: { add: ["a"] }, }), - ).rejects.toThrow(KEYS_POINTER); + ).rejects.toThrow(/not yet implemented/i); + expect(mockRequest).not.toHaveBeenCalled(); }); }); From a46ef65c71386ef985a01064dbdecd22d44d908f Mon Sep 17 00:00:00 2001 From: Ravindra Singh Shekhawat Date: Wed, 19 Aug 2026 21:49:31 +0530 Subject: [PATCH 7/8] fix: canonicalize create segmentType and reuse FME_SEGMENT_KINDS List already rewrote standard to STANDARD; create exact-matched uppercase only, so the same agent input 400ed on POST. --- src/registry/toolsets/feature-flags.ts | 22 ++++++++++++------- tests/registry/feature-flags.test.ts | 17 ++++++++++++-- ...est.ts => fme-segment-native-only.test.ts} | 0 3 files changed, 29 insertions(+), 10 deletions(-) rename tests/registry/{fme-segment-remaining.test.ts => fme-segment-native-only.test.ts} (100%) diff --git a/src/registry/toolsets/feature-flags.ts b/src/registry/toolsets/feature-flags.ts index a03bf77e..9413412a 100644 --- a/src/registry/toolsets/feature-flags.ts +++ b/src/registry/toolsets/feature-flags.ts @@ -35,8 +35,15 @@ const FME_SEGMENT_TYPE_ALIASES: Record = { rule_based: "RULE_BASED", }; -function isFmeSegmentKind(value: unknown): value is FmeSegmentKind { - return typeof value === "string" && (FME_SEGMENT_KINDS as readonly string[]).includes(value); +function canonicalizeFmeSegmentKind(raw: unknown): FmeSegmentKind | undefined { + if (typeof raw !== "string") return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + const byLower = new Map([ + ...FME_SEGMENT_KINDS.map((kind) => [kind.toLowerCase(), kind] as const), + ...Object.entries(FME_SEGMENT_TYPE_ALIASES), + ]); + return byLower.get(trimmed.toLowerCase()); } function resolveFmeCreateSegmentType(body: Record | undefined): FmeSegmentKind { @@ -45,23 +52,22 @@ function resolveFmeCreateSegmentType(body: Record | undefined): let fromPrimary: FmeSegmentKind | undefined; if (primary !== undefined) { - if (!isFmeSegmentKind(primary)) { + fromPrimary = canonicalizeFmeSegmentKind(primary); + if (fromPrimary === undefined) { throw new Error( `fme_segment.create: invalid segmentType '${String(primary)}'. Must be one of: ${FME_SEGMENT_KINDS.join(", ")}.`, ); } - fromPrimary = primary; } let fromAlias: FmeSegmentKind | undefined; if (alias !== undefined) { - const mapped = typeof alias === "string" ? FME_SEGMENT_TYPE_ALIASES[alias] : undefined; - if (mapped === undefined) { + fromAlias = canonicalizeFmeSegmentKind(alias); + if (fromAlias === undefined) { throw new Error( `fme_segment.create: invalid type '${String(alias)}'. Must be one of: ${Object.keys(FME_SEGMENT_TYPE_ALIASES).join(", ")}.`, ); } - fromAlias = mapped; } if (fromPrimary !== undefined && fromAlias !== undefined && fromPrimary !== fromAlias) { @@ -1204,7 +1210,7 @@ export const featureFlagsToolset: ToolsetDefinition = { scopeParams: FME_HARNESS_NATIVE_SCOPE_PARAMS, identifierFields: ["segment_name"], listFilterFields: [ - { name: "segment_type", description: "Required. One kind per list call: STANDARD | LARGE | RULE_BASED (case is canonicalized; unmatched values pass through to the API).", enum: ["STANDARD", "LARGE", "RULE_BASED"], required: true }, + { name: "segment_type", description: "Required. One kind per list call: STANDARD | LARGE | RULE_BASED (case is canonicalized; unmatched values pass through to the API).", enum: [...FME_SEGMENT_KINDS], required: true }, { name: "status", description: "Optional. Filter by segment status (omit for Java default ACTIVE). One value only.", enum: ["ACTIVE", "ARCHIVED"] }, { name: "offset", description: "Pagination offset", type: "number" }, { name: "limit", description: "Page size (max 100, default 100)", type: "number" }, diff --git a/tests/registry/feature-flags.test.ts b/tests/registry/feature-flags.test.ts index 5d07d7ff..799e43fc 100644 --- a/tests/registry/feature-flags.test.ts +++ b/tests/registry/feature-flags.test.ts @@ -1118,6 +1118,19 @@ describe("fme_segment", () => { expect(mockRequest).not.toHaveBeenCalled(); }); + it("create: case-canonicalizes lowercase segmentType to STANDARD", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_segment", "create", { + org_id: "o1", + project_id: "p1", + body: { name: "x", trafficType: "user", segmentType: "standard" }, + }); + + expect(firstRequest(mockRequest).body).toMatchObject({ segmentType: "STANDARD" }); + }); + it("create: rejects an invalid segmentType value without HTTP", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); @@ -1126,9 +1139,9 @@ describe("fme_segment", () => { registry.dispatch(client, "fme_segment", "create", { org_id: "o1", project_id: "p1", - body: { name: "x", trafficType: "user", segmentType: "standard" }, + body: { name: "x", trafficType: "user", segmentType: "bogus" }, }), - ).rejects.toThrow(/invalid segmentType 'standard'/i); + ).rejects.toThrow(/invalid segmentType 'bogus'/i); expect(mockRequest).not.toHaveBeenCalled(); }); diff --git a/tests/registry/fme-segment-remaining.test.ts b/tests/registry/fme-segment-native-only.test.ts similarity index 100% rename from tests/registry/fme-segment-remaining.test.ts rename to tests/registry/fme-segment-native-only.test.ts From fbfde3d661403da914c60ed225b169facc249ed2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 21:23:36 +0000 Subject: [PATCH 8/8] Revert fme_segment_keys description extension per review Remove the added Harness-native key management note from the fme_segment_keys resource description for now. Co-authored-by: Rohan Gupta --- src/registry/toolsets/feature-flags.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registry/toolsets/feature-flags.ts b/src/registry/toolsets/feature-flags.ts index 9413412a..3dd745ad 100644 --- a/src/registry/toolsets/feature-flags.ts +++ b/src/registry/toolsets/feature-flags.ts @@ -1465,7 +1465,7 @@ export const featureFlagsToolset: ToolsetDefinition = { resourceType: "fme_segment_keys", displayName: "FME Segment Keys", description: - "Membership keys (members) of a standard segment. List keys with pagination, or update to add members. Removal is not supported by this endpoint. Limit: 10,000 keys per request, 100,000 per segment total. For Harness-native key management use fme_segment_definition execute list_keys/add_keys/remove_keys.", + "Membership keys (members) of a standard segment. List keys with pagination, or update to add members. Removal is not supported by this endpoint. Limit: 10,000 keys per request, 100,000 per segment total.", toolset: "feature-flags", scope: "account", scopeOptional: true,