Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1507,10 +1507,10 @@ Use `harness_execute(resource_type="pull_request", action="close", ...)` for an
- **`fme_environment`** — `list` wired to the real endpoint.
- **`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_rollout_status`** — dual-mode `list`. Pass `org_id`+`project_id` (preferred) or the deprecated `workspace_id`. Native pagination uses `offset`/`limit` (max 100; `harness_list` `size` maps to `limit`); results are promoted to `items`/`total`. Each item has `id`, `name`, and optional `description`.
- **`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_traffic_type`** — `list` is not yet implemented.
- **`fme_traffic_type`** — dual-mode `list`. Pass `org_id`+`project_id` (preferred) or the deprecated `workspace_id`. Native pagination uses `offset`/`limit` (max 100; `harness_list` `size` maps to `limit`); results are promoted to `items`/`total`. Each item has `id` and `name` (no `displayAttributeId`).
- **`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.
Expand Down
6 changes: 1 addition & 5 deletions src/prompts/feature-flag-rollout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,6 @@ export function registerFeatureFlagRolloutPrompt(server: McpServer): void {
? `workspace_id="${workspaceId}"`
: `org_id="${orgId}", project_id="${projectId}"`;

const nativeModeCaveat = workspaceId
? ""
: "\n\nNote: in Harness-native mode (org_id/project_id), fme_feature_flag_definition, fme_rollout_status, and the kill/restore execute action are not yet implemented server-side and will error — steps 3, 4, and 7 below only work today with workspace_id (legacy mode).";

return {
messages: [{
role: "user" as const,
Expand All @@ -46,7 +42,7 @@ Steps:
6. **Safety gates**: Identify metrics or health checks between each phase
7. **Rollback plan**: Use kill action to immediately turn off the flag if issues arise

Present the rollout plan for review. Use harness_execute with resource_type="fme_feature_flag", action="kill" or action="restore", ${scopeArgs}, feature_flag_name="${featureFlagName}", environment_id=<env_id> to execute each phase after user approval.${nativeModeCaveat}`,
Present the rollout plan for review. Use harness_execute with resource_type="fme_feature_flag", action="kill" or action="restore", ${scopeArgs}, feature_flag_name="${featureFlagName}", environment_id=<env_id> to execute each phase after user approval.`,
},
}],
};
Expand Down
13 changes: 13 additions & 0 deletions src/registry/extractors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,19 @@ export function flattenTrafficType(item: Record<string, unknown>): void {
}
}

/**
* Public v4 paginated lists (`TrafficTypeListResponse` / `RolloutStatusListResponse`):
* `{ data, limit, offset, totalCount }`. Promote `data`→`items` and `totalCount`→`total`
* so harness_list compact/output schema see a full total, not the current page length.
*/
export const fmeV4PaginatedListExtract = (raw: unknown): unknown => {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return raw;
const r = raw as Record<string, unknown>;
if (!Array.isArray(r.data)) return raw;
const total = typeof r.totalCount === "number" ? r.totalCount : r.data.length;
return { ...r, items: r.data, total };
};

/** Extract FME feature flag list — passthrough with trafficType.id flattened on each item. */
export const fmeListExtract = (raw: unknown): unknown => {
if (raw && typeof raw === "object") {
Expand Down
44 changes: 23 additions & 21 deletions src/registry/toolsets/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ToolsetDefinition, BodySchema } from "../types.js";
import { passthrough, fmeListExtract, fmeGetExtract } from "../extractors.js";
import { passthrough, fmeListExtract, fmeGetExtract, fmeV4PaginatedListExtract } from "../extractors.js";
import { isFmeHarnessNativeSelected, logFmeDeprecation, requireFmeIdentifier, requireHarnessNativeSegmentScope, resolveFmeDualMode } from "../scope-utils.js";

const fmeActionExtract = (raw: unknown) => {
Expand Down Expand Up @@ -680,32 +680,33 @@ export const featureFlagsToolset: ToolsetDefinition = {
resourceType: "fme_rollout_status",
displayName: "FME Rollout Status",
description:
"Rollout status definitions for a workspace (e.g. Killed, Permanent, Ramping). Use to discover valid rollout_status_id UUIDs for filtering fme_feature_flag lists. Note: this endpoint may not be available on all account types — rollout status IDs are also returned inline with fme_feature_flag list results.",
"Rollout status definitions (e.g. Killed, Permanent, Ramping). Dual-mode: pass org_id+project_id (preferred) or the deprecated workspace_id. Use harness_list to discover rollout_status_id UUIDs for filtering fme_feature_flag lists. Pagination uses offset/limit (max 100; harness_list size maps to limit).",
toolset: "feature-flags",
scope: "account",
scopeOptional: true,
identifierFields: ["workspace_id"],
product: "fme",
listFilterFields: [
{ name: "workspace_id", description: "FME workspace ID (get from fme_workspace). Deprecated — omit and pass org_id+project_id instead for Harness-native scoping." },
{ name: "offset", description: "Harness-native pagination offset (default 0)", type: "number" },
{ name: "limit", description: "Harness-native page size (default 100, max 100)", type: "number" },
],
operations: {
list: {
method: "GET",
path: "/internal/api/v2/rolloutStatuses/ws/{wsId}",
path: "",
routeResolver: (input) => {
const mode = resolveFmeDualMode(input, "fme_rollout_status");
if (mode.mode === "harness_native") {
throw new Error(
"fme_rollout_status.list: Harness-native (org_id/project_id) mode not yet implemented for this operation — pass workspace_id (deprecated) instead.",
);
if (mode.mode === "legacy") {
return { path: `/internal/api/v2/rolloutStatuses/ws/${encodeURIComponent(mode.workspaceId)}` };
}
return { path: `/internal/api/v2/rolloutStatuses/ws/${encodeURIComponent(mode.workspaceId)}` };
return { path: "/fme/api/v4/rollout-statuses", product: "harness", scopeParams: FME_HARNESS_NATIVE_SCOPE_PARAMS };
},
operationPolicy: { risk: "read", retryPolicy: "safe" },
pathParams: { workspace_id: "wsId" },
responseExtractor: passthrough,
description: "List rollout status definitions for a workspace (Killed, Permanent, Ramping, etc.). If this returns 404, use rolloutStatus fields from fme_feature_flag list results instead.",
queryParams: { offset: "offset", size: "limit", limit: "limit" },
responseExtractor: fmeV4PaginatedListExtract,
description:
"List rollout statuses. Pass org_id+project_id (preferred) or deprecated workspace_id. Optional offset/limit (harness_list size maps to limit).",
},
},
},
Expand Down Expand Up @@ -931,32 +932,33 @@ export const featureFlagsToolset: ToolsetDefinition = {
resourceType: "fme_traffic_type",
displayName: "FME Traffic Type",
description:
"Traffic type in a workspace (e.g. 'user', 'account'). List traffic types to discover traffic_type_id values needed for identity queries and flag/segment creation.",
"Traffic type (e.g. 'user', 'account'). Dual-mode: pass org_id+project_id (preferred) or the deprecated workspace_id. Use harness_list to discover traffic_type_id / name values for flag and segment create. Pagination uses offset/limit (max 100; harness_list size maps to limit).",
toolset: "feature-flags",
scope: "account",
scopeOptional: true,
identifierFields: ["workspace_id"],
product: "fme",
listFilterFields: [
{ name: "workspace_id", description: "FME workspace ID (get from fme_workspace). Deprecated — omit and pass org_id+project_id instead for Harness-native scoping." },
{ name: "offset", description: "Harness-native pagination offset (default 0)", type: "number" },
{ name: "limit", description: "Harness-native page size (default 100, max 100)", type: "number" },
],
operations: {
list: {
method: "GET",
path: "/internal/api/v2/trafficTypes/ws/{wsId}",
path: "",
routeResolver: (input) => {
const mode = resolveFmeDualMode(input, "fme_traffic_type");
if (mode.mode === "harness_native") {
throw new Error(
"fme_traffic_type.list: Harness-native (org_id/project_id) mode not yet implemented for this operation — pass workspace_id (deprecated) instead.",
);
if (mode.mode === "legacy") {
return { path: `/internal/api/v2/trafficTypes/ws/${encodeURIComponent(mode.workspaceId)}` };
}
return { path: `/internal/api/v2/trafficTypes/ws/${encodeURIComponent(mode.workspaceId)}` };
return { path: "/fme/api/v4/traffic-types", product: "harness", scopeParams: FME_HARNESS_NATIVE_SCOPE_PARAMS };
},
operationPolicy: { risk: "read", retryPolicy: "safe" },
pathParams: { workspace_id: "wsId" },
responseExtractor: passthrough,
description: "List traffic types for a workspace. Returns id, name, and displayAttributeId for each traffic type.",
queryParams: { offset: "offset", size: "limit", limit: "limit" },
responseExtractor: fmeV4PaginatedListExtract,
description:
"List traffic types. Pass org_id+project_id (preferred) or deprecated workspace_id. Optional offset/limit (harness_list size maps to limit).",
},
},
},
Expand Down
158 changes: 139 additions & 19 deletions tests/registry/feature-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,145 @@ describe("fme_environment dual-mode routing", () => {
});
});

describe("fme_traffic_type and fme_rollout_status dual-mode list", () => {
let registry: Registry;

beforeEach(() => {
registry = new Registry(makeConfig());
});

it.each(["fme_traffic_type", "fme_rollout_status"] as const)(
"%s descriptions stay tool-facing (no HTTP paths or list-only restatement)",
(resourceType) => {
const resource = findResource(resourceType);
const listDescription = resource.operations.list?.description ?? "";

expect(resource.description).not.toMatch(/\/fme\/api\//);
expect(resource.description).not.toMatch(/\/internal\/api\//);
expect(resource.description).not.toMatch(/List-only/i);
expect(resource.description).not.toMatch(/Native items/i);
expect(resource.description).not.toMatch(/Native results/i);
expect(resource.description).not.toMatch(/displayAttributeId/);
expect(listDescription).not.toMatch(/\/fme\/api\//);
expect(listDescription).not.toMatch(/\/internal\/api\//);
expect(listDescription).not.toMatch(/Native results/i);
expect(resource.description).toMatch(/org_id\+project_id/);
expect(resource.description).toMatch(/workspace_id/);
},
);

it.each([
["fme_traffic_type", "/fme/api/v4/traffic-types", "/internal/api/v2/trafficTypes/ws/ws1"],
["fme_rollout_status", "/fme/api/v4/rollout-statuses", "/internal/api/v2/rolloutStatuses/ws/ws1"],
] as const)("%s: native list uses v4 path and FME scope params", async (resourceType, nativePath, _legacyPath) => {
const mockRequest = vi.fn().mockResolvedValue({});
const client = makeClient(mockRequest);

await registry.dispatch(client, resourceType, "list", { org_id: "o1", project_id: "p1" });

const req = firstRequest(mockRequest);
expect(req.path).toBe(nativePath);
expect(req.product).toBeUndefined();
expect(req.params).toMatchObject({
account_id: "test-account",
organization_identifier: "o1",
project_identifier: "p1",
});
expect(req.params?.orgIdentifier).toBeUndefined();
expect(req.params?.projectIdentifier).toBeUndefined();
});

it.each([
["fme_traffic_type", "/internal/api/v2/trafficTypes/ws/ws1"],
["fme_rollout_status", "/internal/api/v2/rolloutStatuses/ws/ws1"],
] as const)("%s: legacy list keeps Split Admin path", async (resourceType, legacyPath) => {
const mockRequest = vi.fn().mockResolvedValue({});
const client = makeClient(mockRequest);

await registry.dispatch(client, resourceType, "list", { workspace_id: "ws1" });

const req = firstRequest(mockRequest);
expect(req.path).toBe(legacyPath);
expect(req.product).toBe("fme");
});

it("native list forwards offset and limit as query params", async () => {
const mockRequest = vi.fn().mockResolvedValue({});
const client = makeClient(mockRequest);

await registry.dispatch(client, "fme_traffic_type", "list", {
org_id: "o1",
project_id: "p1",
offset: 10,
limit: 25,
});

expect(firstRequest(mockRequest).params).toMatchObject({ offset: 10, limit: 25 });
});

it("native list maps harness_list size onto Java limit", async () => {
const mockRequest = vi.fn().mockResolvedValue({});
const client = makeClient(mockRequest);

await registry.dispatch(client, "fme_rollout_status", "list", {
org_id: "o1",
project_id: "p1",
size: 20,
});

expect(firstRequest(mockRequest).params).toMatchObject({ limit: 20 });
});

it("native list prefers explicit limit over size", async () => {
const mockRequest = vi.fn().mockResolvedValue({});
const client = makeClient(mockRequest);

await registry.dispatch(client, "fme_traffic_type", "list", {
org_id: "o1",
project_id: "p1",
size: 20,
limit: 5,
});

expect(firstRequest(mockRequest).params?.limit).toBe(5);
});

it("native list promotes totalCount to total and data to items", async () => {
const mockRequest = vi.fn().mockResolvedValue({
data: [{ type: "TRAFFIC_TYPE", id: "tt1", name: "user" }],
limit: 100,
offset: 0,
totalCount: 3,
});
const client = makeClient(mockRequest);

const result = await registry.dispatch(client, "fme_traffic_type", "list", {
org_id: "o1",
project_id: "p1",
});

expect(result).toMatchObject({
items: [{ type: "TRAFFIC_TYPE", id: "tt1", name: "user" }],
total: 3,
totalCount: 3,
});
});

it("rejects mixed workspace_id and org/project", async () => {
const mockRequest = vi.fn().mockResolvedValue({});
const client = makeClient(mockRequest);

await expect(
registry.dispatch(client, "fme_rollout_status", "list", {
workspace_id: "ws1",
org_id: "o1",
project_id: "p1",
}),
).rejects.toThrow("fme_rollout_status: pass either workspace_id (deprecated) OR org_id+project_id, not both.");
expect(mockRequest).not.toHaveBeenCalled();
});
});

describe("fme_standard_segment — legacy only, Harness-native rejected in favor of fme_segment", () => {
let registry: Registry;

Expand Down Expand Up @@ -1091,25 +1230,6 @@ describe("FME new-mode (NYI) resources", () => {
registry = new Registry(makeConfig());
});

it.each([
["fme_rollout_status", "list", { workspace_id: "ws1" }],
["fme_traffic_type", "list", { workspace_id: "ws1" }],
] as [string, "get" | "create" | "update" | "list", Record<string, unknown>][])(
"%s.%s: legacy mode still works, new mode throws not-yet-implemented",
async (resourceType, operation, legacyInput) => {
const mockRequest = vi.fn().mockResolvedValue({});
const client = makeClient(mockRequest);

await registry.dispatch(client, resourceType, operation, legacyInput);
expect(mockRequest).toHaveBeenCalledTimes(1);

const newModeInput = { ...legacyInput, workspace_id: undefined, org_id: "o1", project_id: "p1" };
await expect(registry.dispatch(client, resourceType, operation, newModeInput)).rejects.toThrow(
/not yet implemented/i,
);
},
);

it.each([
["list", { workspace_id: "ws1", environment_id: "e1" }],
["update", { workspace_id: "ws1", segment_name: "seg1", environment_id: "e1", body: {} }],
Expand Down
Loading