Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -1487,7 +1487,7 @@ Use `harness_execute(resource_type="pull_request", action="close", ...)` for an
| Resource Type | List | Get | Create | Update | Delete | Execute Actions |
| ----------------------------------- | ---- | --- | ------ | ------ | ------ | ----------------------------------------- |
| `fme_workspace` | x | | | | | |
| `fme_environment` | x | | | | | |
| `fme_environment` | x | x | x | x | x | |
| `fme_feature_flag` | x | x | x | x | x | `kill`, `restore`, `reallocate`, `archive`, `unarchive` |
| `fme_feature_flag_definition` | | x | x | x | | |
| `fme_rollout_status` | x | | | | | |
Expand All @@ -1504,7 +1504,7 @@ Use `harness_execute(resource_type="pull_request", action="close", ...)` for an
**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:

- **`fme_workspace`** — no Harness-native equivalent; legacy-only (used to discover `workspace_id` values).
- **`fme_environment`** — `list` wired to the real endpoint.
- **`fme_environment`** — dual-mode `list` (`workspace_id` or `org_id`+`project_id`). `get`/`create`/`update`/`delete` are Harness-native only (`/fme/api/v4/environments`) — MCP never had a `workspace_id` contract for those ops. Native list uses optional `offset`/`limit` (max 100; `harness_list` `size` maps to `limit`); envelope `{data, limit, offset, totalCount}` is promoted to `items`/`total`. Native create/update use `isProduction` (`production` accepted as an alias). Native update is JSON Merge Patch; `name` and `isProduction` are not clearable. Name max 15 characters.
- **`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.
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 (`EnvironmentListResponse` and siblings):
* `{ 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
122 changes: 118 additions & 4 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 @@ -63,6 +63,61 @@ const fmeFeatureFlagArchiveSchema: BodySchema = {
],
};

const fmeEnvironmentCreateSchema: BodySchema = {
description:
"Create an FME environment (Harness-native org_id+project_id only). name is required (max 15 characters). isProduction is optional (default false). production is accepted as an alias for isProduction.",
fields: [
{ name: "name", type: "string", required: true, description: "Environment name (unique in the project; max 15 characters)" },
{ name: "isProduction", type: "boolean", required: false, description: "Whether this is a production environment. Optional; defaults to false on the backend if omitted." },
{ name: "production", type: "boolean", required: false, description: "Alias for isProduction." },
],
};

const fmeEnvironmentUpdateSchema: BodySchema = {
description:
"Partial environment update via JSON Merge Patch (RFC 7396), Harness-native only. name and isProduction are not clearable — omit to leave unchanged. production is accepted as an alias for isProduction.",
fields: [
{ name: "name", type: "string", required: false, description: "Updated name; omit to leave unchanged. Blank names are rejected. Not clearable." },
{ name: "isProduction", type: "boolean", required: false, description: "Updated production flag. Omit to leave unchanged; not clearable." },
{ name: "production", type: "boolean", required: false, description: "Alias for isProduction." },
],
};

function fmeEnvironmentProduction(body: Record<string, unknown> | undefined): boolean | undefined {
if (!body) return undefined;
if (body.isProduction !== undefined && body.isProduction !== null) return Boolean(body.isProduction);
if (body.production !== undefined && body.production !== null) return Boolean(body.production);
return undefined;
}

/** MCP never had workspace_id get/create/update/delete for environments (#806 list-only). */
function resolveNativeOnlyEnvironmentRoute(
input: Record<string, unknown>,
operation: string,
opts: { collection?: boolean; mergePatch?: boolean } = {},
) {
const mode = resolveFmeDualMode(input, "fme_environment");
if (mode.mode === "legacy") {
throw new Error(
`fme_environment.${operation}: Harness-native (org_id/project_id) only — MCP never supported workspace_id for this operation (list remains dual-mode).`,
);
}
if (opts.collection) {
return {
path: "/fme/api/v4/environments",
product: "harness" as const,
scopeParams: FME_HARNESS_NATIVE_SCOPE_PARAMS,
};
}
const environmentId = encodeURIComponent(requireFmeIdentifier(input, "environment_id", "fme_environment"));
return {
path: `/fme/api/v4/environments/${environmentId}`,
product: "harness" as const,
scopeParams: FME_HARNESS_NATIVE_SCOPE_PARAMS,
...(opts.mergePatch ? { headers: { "Content-Type": "application/merge-patch+json" } } : {}),
};
}

const fmeFeatureFlagDefinitionCreateSchema: BodySchema = {
description: "Create a feature flag definition in a specific environment (initial treatments, rules, and default rule required)",
fields: [
Expand Down Expand Up @@ -227,15 +282,16 @@ export const featureFlagsToolset: ToolsetDefinition = {
resourceType: "fme_environment",
displayName: "FME Environment",
description:
"Feature Management environment. Supports list. Dual-mode scoping: pass either org_id+project_id " +
"(Harness-native, preferred — no workspace lookup needed) or the deprecated workspace_id.",
"Feature Management environment. Dual-mode list (workspace_id or org_id+project_id). get/create/update/delete are Harness-native only — MCP never had a workspace_id contract for those ops. Native create/update use isProduction (production accepted as an alias). Native PATCH is JSON Merge Patch; name and isProduction are not clearable. Name max 15 characters. Delete returns 400 hasDependents while SDK API keys (always created with a new env), flags, or segments remain.",
toolset: "feature-flags",
scope: "account",
scopeOptional: true,
identifierFields: ["workspace_id", "environment_id"],
product: "fme",
listFilterFields: [
{ name: "workspace_id", description: "FME workspace ID (get from harness_list resource_type=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: {
Expand All @@ -249,8 +305,66 @@ export const featureFlagsToolset: ToolsetDefinition = {
return { path: "/fme/api/v4/environments", product: "harness", scopeParams: FME_HARNESS_NATIVE_SCOPE_PARAMS };
},
operationPolicy: { risk: "read", retryPolicy: "safe" },
queryParams: { offset: "offset", size: "limit", limit: "limit" },
responseExtractor: fmeV4PaginatedListExtract,
description: "List FME environments for a workspace (legacy) or org_id+project_id project (Harness-native). Native envelope {data, limit, offset, totalCount} is promoted to items/total; harness_list size maps to limit.",
},
get: {
method: "GET",
path: "",
routeResolver: (input) => resolveNativeOnlyEnvironmentRoute(input, "get"),
operationPolicy: { risk: "read", retryPolicy: "safe" },
responseExtractor: passthrough,
description: "List FME environments for a workspace",
description:
"Get a single environment by environment_id (UUID from list). Harness-native only (org_id+project_id). MCP never supported workspace_id get.",
},
create: {
method: "POST",
path: "",
routeResolver: (input) => resolveNativeOnlyEnvironmentRoute(input, "create", { collection: true }),
operationPolicy: { risk: "low_write", retryPolicy: "do_not_retry" },
skipScopeBodyInjection: true,
bodyBuilder: (input) => {
const body = input.body as Record<string, unknown> | undefined;
const production = fmeEnvironmentProduction(body);
return {
name: body?.name,
...(production !== undefined ? { isProduction: production } : {}),
};
},
responseExtractor: passthrough,
bodySchema: fmeEnvironmentCreateSchema,
description:
"Create an environment (Harness-native only). Body requires name (max 15 characters). Optional isProduction (CreateEnvironmentRequest). NG scope is not injected into the JSON.",
},
update: {
method: "PATCH",
path: "",
routeResolver: (input) => resolveNativeOnlyEnvironmentRoute(input, "update", { mergePatch: true }),
operationPolicy: { risk: "low_write", retryPolicy: "safe" },
skipScopeBodyInjection: true,
bodyBuilder: (input) => {
const body = input.body as Record<string, unknown> | undefined;
if (!body) return {};
const production = fmeEnvironmentProduction(body);
return {
...(typeof body.name === "string" ? { name: body.name } : {}),
...(production !== undefined ? { isProduction: production } : {}),
};
},
responseExtractor: passthrough,
bodySchema: fmeEnvironmentUpdateSchema,
description:
"Update an environment by environment_id (Harness-native only). JSON Merge Patch on name and isProduction (not clearable).",
},
delete: {
method: "DELETE",
path: "",
routeResolver: (input) => resolveNativeOnlyEnvironmentRoute(input, "delete"),
operationPolicy: { risk: "destructive", retryPolicy: "do_not_retry" },
responseExtractor: passthrough,
description:
"Delete (archive) an environment by environment_id (Harness-native only). Returns 400 hasDependents while SDK API keys, flags, or segments still target it. Create via EnvironmentStarterKit always provisions client/server API keys, so a brand-new environment cannot be deleted until those keys are removed.",
},
},
},
Expand Down
67 changes: 67 additions & 0 deletions tests/registry/feature-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,73 @@ describe("fme_environment dual-mode routing", () => {

expect(firstRequest(mockRequest).path).toBe("/internal/api/v2/environments/ws/ws1");
});

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_environment", "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_environment", "list", {
org_id: "o1",
project_id: "p1",
size: 20,
});

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

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

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

expect(result).toMatchObject({
items: [{ id: "env1", name: "prod" }],
total: 3,
totalCount: 3,
});
});

it("legacy list leaves objects envelopes unchanged", async () => {
const mockRequest = vi.fn().mockResolvedValue({
objects: [{ id: "env1", name: "prod" }],
offset: 0,
limit: 20,
});
const client = makeClient(mockRequest);

const result = await registry.dispatch(client, "fme_environment", "list", {
workspace_id: "ws1",
});

expect(result).toEqual({
objects: [{ id: "env1", name: "prod" }],
offset: 0,
limit: 20,
});
});
});

describe("fme_standard_segment — legacy only, Harness-native rejected in favor of fme_segment", () => {
Expand Down
Loading