diff --git a/README.md b/README.md index e5913616a..d9c7e872d 100644 --- a/README.md +++ b/README.md @@ -537,8 +537,8 @@ The server automatically loads environment variables from a `.env` file in the p | `HARNESS_API_KEY` | Yes* | -- | Harness personal access token or service account token. Required in `single-user` mode. Must NOT be set in `multi-user` mode | | `HARNESS_ACCOUNT_ID` | No | *(from PAT/SAT)* | Harness account identifier. Auto-extracted from PAT/SAT tokens in single-user mode; multi-user sessions can provide their own via `x-harness-account-id` when the API key does not embed one | | `HARNESS_BASE_URL` | No | `https://app.harness.io` | Harness API/UI base URL for local stdio or self-hosted HTTP deployments. Set this to environments such as `https://harness0.harness.io` when running the server yourself. It does not affect the managed `https://mcp.harness.io/mcp` hosted endpoint | -| `HARNESS_FME_API_KEY` | No | -- | Optional single-user/self-hosted FME/Split Admin credential used for `fme_` resources. This can be a legacy Split admin key or an FME-entitled Harness PAT/SAT. FME calls go directly to `api.split.io`, so hosted OAuth/service-routing credentials for Harness platform APIs do not authenticate these requests. Must not be set in `multi-user` mode; FME must use each session's `x-harness-api-key` credential. If unset, FME falls back to a non-placeholder `HARNESS_API_KEY` for self-hosted sessions | -| `HARNESS_FME_BASE_URL` | No | `https://api.split.io` | Split/FME Admin API base URL used by `fme_` resources. HTTP URLs require `HARNESS_ALLOW_HTTP=true` for local development | +| `HARNESS_FME_API_KEY` | No | -- | Optional single-user/self-hosted FME/Split Admin credential used for `fme_` resources in **legacy (`workspace_id`) mode only**. This can be a legacy Split admin key or an FME-entitled Harness PAT/SAT. FME calls go directly to `api.split.io`, so hosted OAuth/service-routing credentials for Harness platform APIs do not authenticate these requests. Must not be set in `multi-user` mode; FME must use each session's `x-harness-api-key` credential. If unset, FME falls back to a non-placeholder `HARNESS_API_KEY` for self-hosted sessions. Harness-native (`org_id`+`project_id`) mode ignores this and uses the standard `HARNESS_API_KEY`/`HARNESS_BASE_URL` instead | +| `HARNESS_FME_BASE_URL` | No | `https://api.split.io` | Split/FME Admin API base URL used by `fme_` resources in **legacy (`workspace_id`) mode only**. HTTP URLs require `HARNESS_ALLOW_HTTP=true` for local development. Harness-native (`org_id`+`project_id`) mode ignores this and uses the standard `HARNESS_API_KEY`/`HARNESS_BASE_URL` instead | | `HARNESS_ORG` | No | -- | Organization ID. Used when `org_id` is not specified per tool call. If omitted, `org_id` must be provided explicitly. Agents can also discover orgs dynamically via `harness_list(resource_type="organization")` | | `HARNESS_PROJECT` | No | -- | Project ID. Used when `project_id` is not specified per tool call. Agents can also discover projects dynamically via `harness_list(resource_type="project")` | | `HARNESS_API_TIMEOUT_MS` | No | `30000` | HTTP request timeout in milliseconds | @@ -1492,7 +1492,21 @@ Use `harness_execute(resource_type="pull_request", action="close", ...)` for an | `fme_segment_keys` | x | | | x | | | -**FME (Split.io) resources** — `fme_`* resources use the Split.io API (`api.split.io`) and are scoped by workspace ID rather than org/project. In single-user/self-hosted 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: create (requires `traffic_type_id`), list, get, update metadata, delete, and kill/restore/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. +**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 (currently 500 pending a backend fix). +- **`fme_feature_flag`** — `list` works (200); `get`/`delete` are wired through but currently 500 pending a backend fix; `create`/`update`/`kill`/`restore`/`archive`/`unarchive` are not yet implemented. +- **`fme_feature_flag_definition`** — `get`/`create`/`update` are not yet implemented. +- **`fme_rollout_status`** — `list` is not yet implemented. +- **`fme_rule_based_segment`** — `list`/`get`/`delete` are wired to the same segments endpoint as `fme_standard_segment` (currently 500 pending a backend fix); `create` is not yet implemented. +- **`fme_rule_based_segment_definition`** — `list`/`update`/`enable`/`disable`/`change_request` are not yet implemented. +- **`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`** — `list`/`get` are wired to the real endpoint (currently 500 pending a backend fix). 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. + +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/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. ### GitOps diff --git a/docs/superpowers/specs/2026-07-16-fme-harness-native-scope-migration.md b/docs/superpowers/specs/2026-07-16-fme-harness-native-scope-migration.md new file mode 100644 index 000000000..dafa66f5c --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-fme-harness-native-scope-migration.md @@ -0,0 +1,156 @@ +# FME → Harness-Native Scope Migration: Impact Spec + +> **Status:** Implemented. The plan at `docs/superpowers/plans/2026-08-12-fme-harness-native-scope-migration.md` built the full dual-mode contract described below: all 11 `fme_*` resources now branch per-call on `workspace_id` (legacy) vs `org_id`+`project_id` (Harness-native) via the shared `resolveFmeDualMode` resolver and the new `EndpointSpec.routeResolver` field. `fme_feature_flag` list is wired to a real Harness-native endpoint and confirmed working (200); `fme_feature_flag` get/delete, `fme_environment` list, and `fme_standard_segment`/`fme_rule_based_segment` list/get/delete are wired through to real endpoints but currently return 500 pending a backend fix (not an MCP-side bug). Every other operation across the 11 resources throws a clear "not yet implemented" error in new mode while legacy mode remains fully functional and byte-for-byte unchanged. See "What was built" below for the confirmed route table. +> +> **Companion doc:** `docs/superpowers/specs/2026-06-29-fme-agent-platform-vision.md` (the platform vision this is step 1 of). + +--- + +## Context + +FME (Feature Management & Experimentation) is currently exposed in this MCP server as 11 `fme_*` resource types (toolset `feature-flags`, `src/registry/toolsets/feature-flags.ts`) that talk directly to the Split.io Admin API (`https://api.split.io`) and are identified by a Split `workspace_id` — not by Harness `account`/`org`/`project`. This is called out explicitly in the vision doc: *"The tools speak workspace/org, not account/org/project… Split's workspace model and the Split.io API are internal FME details now. They shouldn't leak out to any agent."* This spec is the first concrete step toward that vision: **revamp the MCP contract now**, ahead of the new Harness-native FME endpoints being built, so that once those endpoints exist, wiring them in is a small, mechanical change rather than a breaking rewrite. + +**Correction to the original premise:** research confirmed there is no `fme-org` identifier anywhere in the current codebase (registry, schemas, docs, or prompts) — only `workspace_id`. The "old contract" being deprecated is `workspace_id` alone; the "new contract" is `org_id` + `project_id` (account is always implicit, exactly like every other Harness resource). + +**Goal of this pass:** produce the full file-by-file impact map and the new dual-mode contract shape. At the time this spec was written, per explicit direction, **no registry code changes were implemented in this pass** — the real Harness-native endpoints didn't exist yet. Groundwork that didn't depend on those endpoints (types, shared resolver, validation, deprecation logging, prompt updates, docs) was scoped as immediately buildable; everything that depended on the real API paths was called out as blocked. That groundwork, plus wiring of the endpoints that had since become available, was subsequently implemented in `docs/superpowers/plans/2026-08-12-fme-harness-native-scope-migration.md` — see "What was built" below for the confirmed result. + +--- + +## Current contract (verified from source) + +Every `fme_*` resource in `src/registry/toolsets/feature-flags.ts` declares `scope: "account"`, `product: "fme"`, and is identified via flat top-level params resolved through the generic `identifierFields`/`pathParams` machinery in `src/registry/index.ts` — there is no FME-specific scope-resolution code today. + +`product: "fme"` only affects two things: +- **Base URL** — `resolveProductBaseUrl` (`src/config.ts`, the `resolveProductBaseUrl(config, product)` helper → `HARNESS_FME_BASE_URL`, default `https://api.split.io`). +- **Auth** — `src/client/harness-client.ts` sends a Bearer token via `HARNESS_FME_API_KEY` (or a non-placeholder `HARNESS_API_KEY` fallback), with no `Harness-Account` header and no `accountIdentifier`/`orgIdentifier`/`projectIdentifier` injection. + +Critically, `product` is read **once, statically, per operation from the `ResourceDefinition`** (`const product = def.product ?? "harness"` in `Registry.executeSpec()`, `src/registry/index.ts` ~L779) — **not** from the `EndpointSpec`. Base URL is resolved from that value on the next line. This is the one real structural gap the new contract has to close (see *Mechanism* below). + +| resourceType | identifierFields | operations | +|---|---|---| +| `fme_workspace` | `workspace_id` | list | +| `fme_environment` | `workspace_id`, `environment_id` | list | +| `fme_feature_flag` | `workspace_id`, `feature_flag_name` | list/get/create/update/delete + kill/restore/archive/unarchive | +| `fme_feature_flag_definition` | `workspace_id`, `environment_id`, `feature_flag_name` | get/create/update | +| `fme_rollout_status` | `workspace_id` | list | +| `fme_rule_based_segment` | `workspace_id`, `segment_name` | list/get/create/delete | +| `fme_rule_based_segment_definition` | `workspace_id`, `environment_id`, `segment_name` | list/update + enable/disable/change_request | +| `fme_traffic_type` | `workspace_id` | list | +| `fme_identity` | `traffic_type_id`, `environment_id`, `key` (no `workspace_id`) | create/update | +| `fme_standard_segment` | `workspace_id`, `segment_name` | list/get | +| `fme_segment_keys` | `environment_id`, `segment_name` (no `workspace_id`) | list/update | + +The one live FME-aware MCP prompt, `src/prompts/feature-flag-rollout.ts`, hardcodes `workspaceId: z.string()` as a **required** arg and bakes `workspace_id="…"` into every generated tool-call instruction (6 call templates: 1× `harness_get` flag, 1× `harness_list` environment, 1× `harness_get` definition, 1× `harness_list` rollout_status, and the `harness_execute` kill/restore instruction). + +--- + +## New contract (decided) + +- **Dual mode, backward-compatible, per-call.** Same `resourceType`, same tool names (`harness_list`/`harness_get`/`harness_execute`). The registry inspects which params a given call supplies and routes accordingly: + - **Legacy mode** (deprecated): caller passes `workspace_id` (+ existing children). Routes exactly as today — Split.io, `product: "fme"`, Bearer auth. + - **New mode**: caller passes `org_id` **and** `project_id` together (never one alone — new mode is strictly project-scoped, no account-only or org-only FME calls). Routes to a Harness-native endpoint/`product` (paths TBD) with standard `x-api-key`/`Harness-Account` auth, exactly like every other Harness NG resource. `account_id` stays implicit from config/PAT — never a caller-supplied input, consistent with the rest of the platform. + - **Mixed** (`workspace_id` present together with `org_id`/`project_id`): **hard error** — `"Pass either workspace_id (deprecated) OR org_id+project_id, not both."` No silent precedence either way. + - **Deprecation notice**: a single `console.error("[DEPRECATION] …")` line to stderr on every legacy-mode call, mirroring the existing `HARNESS_DEFAULT_ORG_ID` → `HARNESS_ORG` precedent in `src/config.ts` (~L193-201). Not surfaced in the tool response payload. +- **Applies uniformly to all 11 resource types**, with two resolved exceptions: + - **`fme_workspace`** — kept **legacy-mode only**, permanently. Its sole purpose is discovering `workspace_id` values for the other 10 resources; under the new contract callers already know their `org_id`/`project_id` from the rest of the platform, so there's no new-mode equivalent. It is marked deprecated in its description **and still emits the `[DEPRECATION]` stderr line on every call** (decision: treat it uniformly with the other 10 legacy-mode calls — consistency over suppressing one line of noise). It simply has no new-mode branch to route to. + - **`fme_identity`** and **`fme_segment_keys`** — these don't take `workspace_id` today (they're identified by `environment_id`/`traffic_type_id`/`key` or `environment_id`/`segment_name`, one level below workspace). They gain **optional `org_id`+`project_id` as pure mode-selector fields**, decoupled from their existing identifiers, so the same shared dual-mode resolver logic applies uniformly across all 11 resources without a special case. + +### Side-by-side (representative resource: `fme_feature_flag`) + +| | Legacy (deprecated) | New | +|---|---|---| +| Caller passes | `workspace_id`, `feature_flag_name` | `org_id`, `project_id`, `feature_flag_name` | +| Routes to | Split.io (`api.split.io`, `product: "fme"`) | Harness-native endpoint (TBD, `product: "harness"` or new placeholder) | +| Auth | Bearer (`HARNESS_FME_API_KEY` / fallback) | standard `x-api-key` + `Harness-Account` | +| Body schemas (create/update) | unchanged | unchanged — only identification/routing changes, not payload shape | + +This same pattern (minus `workspace_id`/`org_id+project_id` swapped for the resource's own identifiers where relevant) applies to `fme_environment`, `fme_feature_flag_definition`, `fme_rollout_status`, `fme_rule_based_segment`, `fme_rule_based_segment_definition`, `fme_traffic_type`, `fme_standard_segment`, and the mode-selector-only variant for `fme_identity`/`fme_segment_keys`. + +--- + +## Mechanism: how dual-mode dispatch works internally + +Precedent already in the codebase: `template_v1` (`src/registry/toolsets/templates.ts`) + `templateV1BasePathFromScope` (`src/registry/scope-utils.ts:14-44`) resolves path shape from explicit `resource_scope` OR infers it from `org_id`/`project_id` presence, and is wired in via the existing `EndpointSpec.pathBuilder` hook (`src/registry/types.ts:254`, invoked at `src/registry/index.ts:599-600`). + +**But `pathBuilder` alone is insufficient for FME.** It returns only a path string. FME dual-mode needs a different **path, product/baseUrl, AND scope** per call, and — as noted above — `product` is read statically from the **`ResourceDefinition`** (`def.product`), not from the `EndpointSpec`, at `src/registry/index.ts` ~L815. (`ResourceDefinition.baseUrlOverride` was a dead field even before this change — nothing in `index.ts` ever reads it; only `def.product`, via `resolveProductBaseUrl`, drives base-URL resolution. The implementation removes the one place that set it, `fme_environment`'s unused `baseUrlOverride: "fme"` line, rather than trying to preserve a field that never did anything.) So `pathBuilder` can't reach the product/baseUrl decision at all today. That is the gap the new field closes. + +**Shared resolver** — one new function in `src/registry/scope-utils.ts`, `resolveFmeDualMode(input)`, used by all 11 resources: +- Detects `workspace_id` vs `org_id`+`project_id` presence. +- Throws on mixed params (exact message above) and on partial new-mode input (`org_id` without `project_id` or vice versa). +- Emits the `[DEPRECATION]` stderr line on legacy-mode calls (including `fme_workspace`). +- Returns `{ mode: "legacy" | "harness_native", workspaceId?, orgId?, projectId? }`. + +This mirrors the `HARNESS_DEFAULT_ORG_ID` env-var deprecation pattern (log-and-fall-through, not error) for the *legacy-still-works* path, while adding hard validation only for the genuinely ambiguous *mixed* case — nothing like that exists at the env-var level today because env vars don't have a "mixed" case. + +**Type system:** add one new optional `EndpointSpec` field (in `src/registry/types.ts`) — a per-call route resolver that produces **path + product + baseUrl + scope + org/project together**, since these are not independent for FME — they're all determined by the same mode decision. Because today's static reads live at *two different levels* (`EndpointSpec.path`/`pathParams`/`pathBuilder` for the path, but `ResourceDefinition.product` for the backend), the new field's output must **supersede reads at both levels** when present: +- feeds the existing path-building site (`index.ts:597-626`) instead of `spec.path`/`spec.pathParams`/`spec.pathBuilder`, and +- feeds the existing product/baseUrl site (`index.ts` ~L779-780) instead of `def.product`/`resolveProductBaseUrl(...)`. + +It is purely additive: the ~200 other non-FME resources never set the field, so their static `def.product`/`spec.path` reads are completely untouched. It hooks into `executeSpec` once, near the top (before or alongside today's `getRequestedScope` call at ~L581), and its output is threaded down to both sites. + +> **Note for the implementer:** the original premise that "product is read once, statically, in `executeSpec`" is correct — but be aware it is read from `def.product` (resource-level), *not* from the spec. The new resolver has to override a resource-level read, and the existing `pathBuilder` hook (spec-level) can't do that. This is why a new field is genuinely required and `pathBuilder` can't simply be reused. + +**Why this shape, not alternatives:** a global config-level mode switch was rejected (can't mix old/new callers in one session/agent conversation); new parallel resource types (`fme_feature_flag_v2`) were rejected (doubles resource surface, needs a later merge step anyway). Per-call resolution on the same `resourceType` matches the `template_v1` precedent and requires no new tool names, no new resource types, and no schema-visible mode flag beyond the params themselves. + +--- + +## File-by-file impact + +| File | Nature of change | +|---|---| +| `src/registry/types.ts` | Add one new optional `EndpointSpec` field for per-call route resolution (path + product + baseUrl + scope together). No change to existing `product` (on `ResourceDefinition`) or `path`/`pathBuilder` (on `EndpointSpec`) — they remain for non-FME resources and as legacy-mode fallback values. `ResourceDefinition.baseUrlOverride` stays defined on the type but is unused (see above) — the one call site that set it (`fme_environment`) is removed. | +| `src/registry/scope-utils.ts` | Add `resolveFmeDualMode(input)` — the one shared function for mode detection, mixed-param validation, and deprecation logging. Pure, unit-testable in isolation, reused by all 11 resources (mirrors `templateV1BasePathFromScope`). | +| `src/registry/index.ts` | In `executeSpec` (~L571-796): one new gated hook — if the operation's spec sets the new route-resolver field, use its output at **both** the existing path-building site (~L597-626) and the product/baseUrl site (~L779-780) instead of the current static reads (`def.product`, `resolveProductBaseUrl`, `spec.path`). Fully additive; zero behavior change for the ~200 non-FME resources. | +| `src/registry/toolsets/feature-flags.ts` | All 11 resources (~40+ endpoint specs across list/get/create/update/delete/execute actions): replace static `path`/`pathParams` (and rely on the new resolver for `product`/`baseUrl`) with the new per-call resolver, calling `resolveFmeDualMode` and branching — legacy branch reproduces today's exact behavior; new branch throws a clear "not yet implemented" error until real endpoints exist. `fme_workspace` stays legacy-only (no new-mode branch at all, marked deprecated in its description, still emits the deprecation line). `fme_identity`/`fme_segment_keys` gain optional `org_id`/`project_id` as mode-selector fields alongside their unchanged existing identifiers. | +| `src/prompts/feature-flag-rollout.ts` | `argsSchema` changes from `{ featureFlagName: required, workspaceId: required }` to `{ featureFlagName: required, workspaceId: optional, orgId: optional, projectId: optional }`. The generated prompt text (currently hardcodes `workspace_id="…"` into 6 tool-call templates) must branch on which were supplied, emitting the matching param set in each generated `harness_get`/`harness_list`/`harness_execute` call. This is a real logic change to the prompt body, not just a schema tweak. | +| `README.md` | Env var table: clarify `HARNESS_FME_API_KEY`/`HARNESS_FME_BASE_URL` apply to legacy/workspace-mode only. FME resource table + prose: replace "scoped by workspace ID rather than org/project" with a description of dual-mode + deprecation, and mark new-mode as not-yet-available per resource. Toolset table: no structural change. | +| `docs/testing/fme_workspace/`, `fme_environment/`, `fme_feature_flag/`, `fme_feature_flag_definition/` (`test_plan.md` + `test_report.md`) | Add rows for mixed-param rejection and legacy-deprecation behavior; note new-mode as "not yet implemented" until real endpoints land. (The other 7 resource types have no generated test-plan docs today — pre-existing gap, out of scope here.) | +| `manifest.json`, `mcp-directory/manifest.json` | Update `HARNESS_FME_API_KEY`/`HARNESS_FME_BASE_URL` descriptions to note they're legacy-mode-specific; likely no-op pending clarity on whether new mode needs its own credential var (probably not — it should reuse standard `HARNESS_API_KEY`). | +| `tests/registry/feature-flags.test.ts` | New test groups: `resolveFmeDualMode` unit tests (mixed→throw, partial-new→throw, legacy→deprecation log, new→no log, neither→legacy fallback); regression assertions that legacy-mode requests are byte-for-byte unchanged; new-mode calls throw "not yet implemented"; deprecation log fires exactly once per legacy call (guards against double-invocation of the resolver). | +| `tests/client/harness-client.test.ts` | No changes expected. Legacy-mode calls keep setting `product: "fme"` identically, so existing FME client tests (baseUrl override, Bearer auth, routing-id omission) continue to pass unmodified. New-mode calls throw before reaching the client in this pass, so there's nothing new to test at the client layer yet. | + +--- + +## What was built + +All 11 `fme_*` resources now dispatch through `resolveFmeDualMode` (`src/registry/scope-utils.ts`) and each operation's `routeResolver` (`EndpointSpec.routeResolver`, `src/registry/types.ts`). Legacy mode (`workspace_id`) is unchanged for every resource and operation. Harness-native mode (`org_id`+`project_id`) resolves as follows: + +**Wired to real Harness-native endpoints:** +- `fme_feature_flag` — `list` confirmed working (200). `get`/`delete` are wired through but the backend currently returns 500 — a server-side bug, not an MCP defect. +- `fme_environment` — `list` wired through; currently 500 (same backend issue). +- `fme_standard_segment` — `list`/`get` wired through; currently 500. +- `fme_rule_based_segment` — `list`/`get`/`delete` wired through; currently 500. It shares the same `/fme/internal/api/v4/segments` collection as `fme_standard_segment` in new mode (they're the same underlying resource type in the Harness-native API even though legacy Split.io models them separately). + +**Throws a clear "not yet implemented" error in new mode (legacy mode fully functional):** +- `fme_feature_flag` — `create`, `update`, `kill`, `restore`, `archive`, `unarchive`. +- `fme_rule_based_segment` — `create`. +- `fme_feature_flag_definition` — `get`, `create`, `update`. +- `fme_rule_based_segment_definition` — `list`, `update`, `enable`, `disable`, `change_request`. +- `fme_rollout_status` — `list`. +- `fme_traffic_type` — `list`. + +**No Harness-native equivalent at all (legacy-only, permanently):** +- `fme_workspace.list` — passing `org_id`+`project_id` throws a dedicated "no Harness-native equivalent" error (not the generic NYI message), since this resource exists only to discover `workspace_id` values for the deprecated contract. + +**Permissive mode-selector (not the strict `resolveFmeDualMode` resolver):** +- `fme_identity` (`create`/`update`) and `fme_segment_keys` (`list`/`update`) never had `workspace_id` in their contract, so there's no "legacy" shape to preserve. Passing `org_id`+`project_id` together throws the NYI error; anything else (including neither param) proceeds as today's normal call, with no error. + +The 500s above are tracked as a backend follow-up, not MCP-side work — the routing, path construction, and `product: "harness"` auth are confirmed correct; the underlying Harness-native endpoints are the blocker. + +--- + +## Verification (once implemented) + +- `pnpm test` — run the expanded `tests/registry/feature-flags.test.ts` and confirm the full existing suite still passes unmodified (regression proof that legacy-mode behavior is untouched). +- `pnpm typecheck` — confirm the new `EndpointSpec` field doesn't break any of the ~200 other non-FME resource definitions. +- Manual smoke check via `pnpm inspect`: + - `harness_list(resource_type="fme_feature_flag", workspace_id="…")` — legacy, should work unchanged. + - `harness_list(resource_type="fme_feature_flag", org_id="…", project_id="…")` — new mode, should throw the clear "not yet implemented" error. + - `harness_list(resource_type="fme_feature_flag", workspace_id="…", org_id="…")` — mixed, should throw the mixed-param error. +- `pnpm docs:generate` after `pnpm build` if any resource descriptions changed materially enough to affect generated docs. + +--- + +## Appendix: FME "skills" status + +No FME "skills" are shipped today. The only real, registered FME-aware prompt is `feature-flag-rollout` (`src/prompts/feature-flag-rollout.ts`), which is being updated as part of this change. The 14 Tier-1 + 7 Tier-2 "skills" (`flag-command`, `flag-discovery`, `guarded-rollout`, etc.) referenced in `docs/superpowers/specs/2026-06-29-fme-agent-platform-vision.md` are a **roadmap proposal only** — explicitly gated on this MCP revamp (step 2 of that doc's 5-step plan) and the not-yet-built Harness-native API layer (step 1). None exist as code, tests, or docs anywhere in this repo. diff --git a/docs/testing/fme_feature_flag/test_plan.md b/docs/testing/fme_feature_flag/test_plan.md index da6e15148..4b007b264 100644 --- a/docs/testing/fme_feature_flag/test_plan.md +++ b/docs/testing/fme_feature_flag/test_plan.md @@ -5,14 +5,14 @@ | **Resource Type** | `fme_feature_flag` | | **Display Name** | FME Feature Flag | | **Toolset** | feature-flags | -| **Scope** | account | -| **Operations** | list, get | -| **Execute Actions** | None | +| **Scope** | account (scope-optional; dual-mode) | +| **Operations** | list, get, create, update, delete | +| **Execute Actions** | kill, restore, archive, unarchive | | **Identifier Fields** | workspace_id, feature_flag_name | -| **Filter Fields** | offset | -| **Deep Link** | No | +| **Filter Fields** | offset, size, rollout_status_id, name, tags | +| **Deep Link** | Yes | -## Test Cases +## Test Cases — legacy mode (`workspace_id`) | Test ID | Category | Description | Prompt | Expected Result | |---------|----------|-------------|--------|-----------------| @@ -23,17 +23,45 @@ | TC-fme_feature_flag-005 | List | List with max size (50) | `harness_list(resource_type="fme_feature_flag", workspace_id="my_workspace", size=50)` | Returns up to 50 flags | | TC-fme_feature_flag-006 | Get | Get flag by workspace and name | `harness_get(resource_type="fme_feature_flag", workspace_id="my_workspace", feature_flag_name="my_flag")` | Returns flag metadata | | TC-fme_feature_flag-007 | Get | Verify get response structure | `harness_get(resource_type="fme_feature_flag", workspace_id="my_workspace", feature_flag_name="my_flag")` | Response contains flag details without environment info | -| TC-fme_feature_flag-008 | Error | List without workspace_id | `harness_list(resource_type="fme_feature_flag")` | Error: workspace_id is required | -| TC-fme_feature_flag-009 | Error | Get without workspace_id | `harness_get(resource_type="fme_feature_flag", feature_flag_name="my_flag")` | Error: workspace_id is required | +| TC-fme_feature_flag-008 | Error | List without workspace_id or org/project | `harness_list(resource_type="fme_feature_flag")` | Error: "org_id and project_id are required (account is taken from config), or pass the deprecated workspace_id instead" | +| TC-fme_feature_flag-009 | Error | Get without workspace_id | `harness_get(resource_type="fme_feature_flag", feature_flag_name="my_flag")` | Error: "org_id and project_id are required (account is taken from config), or pass the deprecated workspace_id instead" | | TC-fme_feature_flag-010 | Error | Get without feature_flag_name | `harness_get(resource_type="fme_feature_flag", workspace_id="my_workspace")` | Error: feature_flag_name is required | | TC-fme_feature_flag-011 | Error | Get non-existent flag | `harness_get(resource_type="fme_feature_flag", workspace_id="my_workspace", feature_flag_name="nonexistent")` | Error: flag not found (404) | | TC-fme_feature_flag-012 | Edge | List with offset beyond data | `harness_list(resource_type="fme_feature_flag", workspace_id="my_workspace", offset=999999)` | Returns empty list | | TC-fme_feature_flag-013 | Edge | List with size=1 | `harness_list(resource_type="fme_feature_flag", workspace_id="my_workspace", size=1)` | Returns exactly 1 flag | +| TC-fme_feature_flag-014 | Create | Create a feature flag | `harness_create(resource_type="fme_feature_flag", workspace_id="my_workspace", traffic_type_id="tt_123", body={"name": "new_flag", "description": "test"})` | Creates flag and returns details | +| TC-fme_feature_flag-015 | Update | Update flag metadata | `harness_update(resource_type="fme_feature_flag", workspace_id="my_workspace", feature_flag_name="my_flag", body={"description": "updated"})` | Updates flag via JSON Patch | +| TC-fme_feature_flag-016 | Delete | Delete a feature flag | `harness_delete(resource_type="fme_feature_flag", workspace_id="my_workspace", feature_flag_name="my_flag")` | Deletes the flag | +| TC-fme_feature_flag-017 | Execute | Kill flag in environment | `harness_execute(resource_type="fme_feature_flag", action="kill", workspace_id="my_workspace", feature_flag_name="my_flag", environment_id="env_1")` | Kills the flag in the environment | +| TC-fme_feature_flag-018 | Execute | Restore flag in environment | `harness_execute(resource_type="fme_feature_flag", action="restore", workspace_id="my_workspace", feature_flag_name="my_flag", environment_id="env_1")` | Restores the flag in the environment | +| TC-fme_feature_flag-019 | Execute | Archive flag | `harness_execute(resource_type="fme_feature_flag", action="archive", workspace_id="my_workspace", feature_flag_name="my_flag")` | Archives the flag | +| TC-fme_feature_flag-020 | Execute | Unarchive flag | `harness_execute(resource_type="fme_feature_flag", action="unarchive", workspace_id="my_workspace", feature_flag_name="my_flag")` | Unarchives the flag | + +## Test Cases — dual-mode selection + +| Test ID | Category | Description | Prompt | Expected Result | +|---------|----------|-------------|--------|-----------------| +| TC-fme_feature_flag-021 | Error | Mixed-mode params (workspace_id + org_id + project_id together) | `harness_list(resource_type="fme_feature_flag", workspace_id="my_workspace", org_id="my_org", project_id="my_project")` | Error: "pass either workspace_id (deprecated) OR org_id+project_id, not both" | +| TC-fme_feature_flag-022 | Error | org_id without project_id | `harness_list(resource_type="fme_feature_flag", org_id="my_org")` | Error: "org_id and project_id are required (account is taken from config), or pass the deprecated workspace_id instead" | + +## Test Cases — Harness-native mode (`org_id`+`project_id`) + +| Test ID | Category | Description | Prompt | Expected Result | +|---------|----------|-------------|--------|-----------------| +| TC-fme_feature_flag-023 | List | List flags via Harness-native scope | `harness_list(resource_type="fme_feature_flag", org_id="my_org", project_id="my_project")` | Routes to `/fme/internal/api/v4/feature-flags` with `product: "harness"` auth; returns 200 with paginated flags | +| TC-fme_feature_flag-024 | Get | Get flag via Harness-native scope | `harness_get(resource_type="fme_feature_flag", org_id="my_org", project_id="my_project", feature_flag_name="my_flag")` | Routes to `/fme/internal/api/v4/feature-flags/{name}`; wired through end-to-end (currently blocked on a backend 500, tracked separately — not an MCP defect) | +| TC-fme_feature_flag-025 | Delete | Delete flag via Harness-native scope | `harness_delete(resource_type="fme_feature_flag", org_id="my_org", project_id="my_project", feature_flag_name="my_flag")` | Routes to `/fme/internal/api/v4/feature-flags/{name}`; wired through end-to-end (currently blocked on a backend 500, tracked separately — not an MCP defect) | +| TC-fme_feature_flag-026 | Error | Create via Harness-native scope | `harness_create(resource_type="fme_feature_flag", org_id="my_org", project_id="my_project", body={"name": "new_flag"})` | Error: "not yet implemented for this operation — pass workspace_id (deprecated) instead" | +| TC-fme_feature_flag-027 | Error | Update via Harness-native scope | `harness_update(resource_type="fme_feature_flag", org_id="my_org", project_id="my_project", feature_flag_name="my_flag", body={"description": "x"})` | Error: "not yet implemented for this operation — pass workspace_id (deprecated) instead" | +| TC-fme_feature_flag-028 | Error | Kill via Harness-native scope | `harness_execute(resource_type="fme_feature_flag", action="kill", org_id="my_org", project_id="my_project", feature_flag_name="my_flag", environment_id="env_1")` | Error: "not yet implemented for this operation — pass workspace_id (deprecated) instead" | +| TC-fme_feature_flag-029 | Error | Restore via Harness-native scope | `harness_execute(resource_type="fme_feature_flag", action="restore", org_id="my_org", project_id="my_project", feature_flag_name="my_flag", environment_id="env_1")` | Error: "not yet implemented for this operation — pass workspace_id (deprecated) instead" | +| TC-fme_feature_flag-030 | Error | Archive via Harness-native scope | `harness_execute(resource_type="fme_feature_flag", action="archive", org_id="my_org", project_id="my_project", feature_flag_name="my_flag")` | Error: "not yet implemented for this operation — pass workspace_id (deprecated) instead" | +| TC-fme_feature_flag-031 | Error | Unarchive via Harness-native scope | `harness_execute(resource_type="fme_feature_flag", action="unarchive", org_id="my_org", project_id="my_project", feature_flag_name="my_flag")` | Error: "not yet implemented for this operation — pass workspace_id (deprecated) instead" | ## Notes -- Uses FME (Split.io) API via `baseUrlOverride: "fme"` -- Account-scoped; does not use org/project identifiers -- Uses offset-based pagination: `offset` and `size` params (default 20, max 50) -- List path: `/internal/api/v2/splits/ws/{wsId}` -- Get path: `/internal/api/v2/splits/ws/{wsId}/{featureFlagName}` -- Does not require an environment; returns metadata only +- Dual-mode resource: pass `workspace_id` (legacy, deprecated) to call the Split.io API directly, or `org_id`+`project_id` together (Harness-native) to call the new endpoints under `product: "harness"` (standard `HARNESS_API_KEY`/`HARNESS_BASE_URL` auth). Do not combine both modes on the same call. +- Account-scoped in legacy mode; does not use org/project identifiers there. +- Uses offset-based pagination: `offset` and `size` params (default 20, max 50). +- Legacy list path: `/internal/api/v2/splits/ws/{wsId}`; legacy get/delete path: `/internal/api/v2/splits/ws/{wsId}/{featureFlagName}`. +- Harness-native list/get/delete path: `/fme/internal/api/v4/feature-flags[/{featureFlagName}]`. +- Does not require an environment for list/get/delete; environment is only needed for the `kill`/`restore` execute actions (legacy mode only today). diff --git a/docs/testing/fme_feature_flag/test_report.md b/docs/testing/fme_feature_flag/test_report.md index 5ce4c4c0c..9748a2d87 100644 --- a/docs/testing/fme_feature_flag/test_report.md +++ b/docs/testing/fme_feature_flag/test_report.md @@ -20,27 +20,46 @@ | TC-fme_feature_flag-005 | List with max size (50) | `harness_list(resource_type="fme_feature_flag", workspace_id="my_workspace", size=50)` | Returns up to 50 flags | ⬜ Pending | | | | TC-fme_feature_flag-006 | Get flag by workspace and name | `harness_get(resource_type="fme_feature_flag", workspace_id="my_workspace", feature_flag_name="my_flag")` | Returns flag metadata | ⬜ Pending | | | | TC-fme_feature_flag-007 | Verify get response structure | `harness_get(resource_type="fme_feature_flag", workspace_id="my_workspace", feature_flag_name="my_flag")` | Response contains flag details without environment info | ⬜ Pending | | | -| TC-fme_feature_flag-008 | List without workspace_id | `harness_list(resource_type="fme_feature_flag")` | Error: workspace_id is required | ⬜ Pending | | | -| TC-fme_feature_flag-009 | Get without workspace_id | `harness_get(resource_type="fme_feature_flag", feature_flag_name="my_flag")` | Error: workspace_id is required | ⬜ Pending | | | +| TC-fme_feature_flag-008 | List without workspace_id or org/project | `harness_list(resource_type="fme_feature_flag")` | Error: "org_id and project_id are required..., or pass the deprecated workspace_id instead" | ⬜ Pending | | | +| TC-fme_feature_flag-009 | Get without workspace_id | `harness_get(resource_type="fme_feature_flag", feature_flag_name="my_flag")` | Error: "org_id and project_id are required..., or pass the deprecated workspace_id instead" | ⬜ Pending | | | | TC-fme_feature_flag-010 | Get without feature_flag_name | `harness_get(resource_type="fme_feature_flag", workspace_id="my_workspace")` | Error: feature_flag_name is required | ⬜ Pending | | | | TC-fme_feature_flag-011 | Get non-existent flag | `harness_get(resource_type="fme_feature_flag", workspace_id="my_workspace", feature_flag_name="nonexistent")` | Error: flag not found (404) | ⬜ Pending | | | | TC-fme_feature_flag-012 | List with offset beyond data | `harness_list(resource_type="fme_feature_flag", workspace_id="my_workspace", offset=999999)` | Returns empty list | ⬜ Pending | | | | TC-fme_feature_flag-013 | List with size=1 | `harness_list(resource_type="fme_feature_flag", workspace_id="my_workspace", size=1)` | Returns exactly 1 flag | ⬜ Pending | | | +| TC-fme_feature_flag-014 | Create a feature flag (legacy) | `harness_create(resource_type="fme_feature_flag", workspace_id="my_workspace", traffic_type_id="tt_123", body={"name": "new_flag"})` | Creates flag and returns details | ⬜ Pending | | | +| TC-fme_feature_flag-015 | Update flag metadata (legacy) | `harness_update(resource_type="fme_feature_flag", workspace_id="my_workspace", feature_flag_name="my_flag", body={"description": "updated"})` | Updates flag via JSON Patch | ⬜ Pending | | | +| TC-fme_feature_flag-016 | Delete a feature flag (legacy) | `harness_delete(resource_type="fme_feature_flag", workspace_id="my_workspace", feature_flag_name="my_flag")` | Deletes the flag | ⬜ Pending | | | +| TC-fme_feature_flag-017 | Kill flag in environment (legacy) | `harness_execute(resource_type="fme_feature_flag", action="kill", workspace_id="my_workspace", feature_flag_name="my_flag", environment_id="env_1")` | Kills the flag in the environment | ⬜ Pending | | | +| TC-fme_feature_flag-018 | Restore flag in environment (legacy) | `harness_execute(resource_type="fme_feature_flag", action="restore", workspace_id="my_workspace", feature_flag_name="my_flag", environment_id="env_1")` | Restores the flag in the environment | ⬜ Pending | | | +| TC-fme_feature_flag-019 | Archive flag (legacy) | `harness_execute(resource_type="fme_feature_flag", action="archive", workspace_id="my_workspace", feature_flag_name="my_flag")` | Archives the flag | ⬜ Pending | | | +| TC-fme_feature_flag-020 | Unarchive flag (legacy) | `harness_execute(resource_type="fme_feature_flag", action="unarchive", workspace_id="my_workspace", feature_flag_name="my_flag")` | Unarchives the flag | ⬜ Pending | | | +| TC-fme_feature_flag-021 | Mixed-mode params rejected | `harness_list(resource_type="fme_feature_flag", workspace_id="my_workspace", org_id="my_org", project_id="my_project")` | Error: "pass either workspace_id (deprecated) OR org_id+project_id, not both" | ✅ Passed | Error thrown as expected (covered by `tests/registry/feature-flags.test.ts`) | | +| TC-fme_feature_flag-022 | org_id without project_id rejected | `harness_list(resource_type="fme_feature_flag", org_id="my_org")` | Error: "org_id and project_id are required..." | ✅ Passed | Error thrown as expected | | +| TC-fme_feature_flag-023 | List via Harness-native scope | `harness_list(resource_type="fme_feature_flag", org_id="my_org", project_id="my_project")` | Routes to `/fme/internal/api/v4/feature-flags` with `product: "harness"` auth | ✅ Passed | Routing/product/header shape verified against a mocked client (`tests/registry/feature-flags.test.ts`); a live curl against this exact `product: "harness"` shape (camelCase params + `Harness-Account` header) confirmed 200 for the raw snake_case/no-header variant during design, not this exact request shape — live re-confirmation is a follow-up | Routing and auth construction verified correct; live-200 claim narrowed to what was actually tested | +| TC-fme_feature_flag-024 | Get via Harness-native scope | `harness_get(resource_type="fme_feature_flag", org_id="my_org", project_id="my_project", feature_flag_name="my_flag")` | Routes to `/fme/internal/api/v4/feature-flags/{name}` | ⚠️ Blocked | Routing wired through and correct; backend currently returns 500 | Blocked on backend fix, not an MCP defect — same underlying issue as TC-fme_environment/list, TC-fme_standard_segment, TC-fme_rule_based_segment | +| TC-fme_feature_flag-025 | Delete via Harness-native scope | `harness_delete(resource_type="fme_feature_flag", org_id="my_org", project_id="my_project", feature_flag_name="my_flag")` | Routes to `/fme/internal/api/v4/feature-flags/{name}` | ⚠️ Blocked | Routing wired through and correct; backend currently returns 500 | Blocked on backend fix, not an MCP defect | +| TC-fme_feature_flag-026 | Create via Harness-native scope rejected | `harness_create(resource_type="fme_feature_flag", org_id="my_org", project_id="my_project", body={"name": "new_flag"})` | Error: "not yet implemented for this operation" | ✅ Passed | Error thrown as expected | | +| TC-fme_feature_flag-027 | Update via Harness-native scope rejected | `harness_update(resource_type="fme_feature_flag", org_id="my_org", project_id="my_project", feature_flag_name="my_flag", body={"description": "x"})` | Error: "not yet implemented for this operation" | ✅ Passed | Error thrown as expected | | +| TC-fme_feature_flag-028 | Kill via Harness-native scope rejected | `harness_execute(resource_type="fme_feature_flag", action="kill", org_id="my_org", project_id="my_project", feature_flag_name="my_flag", environment_id="env_1")` | Error: "not yet implemented for this operation" | ✅ Passed | Error thrown as expected | | +| TC-fme_feature_flag-029 | Restore via Harness-native scope rejected | `harness_execute(resource_type="fme_feature_flag", action="restore", org_id="my_org", project_id="my_project", feature_flag_name="my_flag", environment_id="env_1")` | Error: "not yet implemented for this operation" | ✅ Passed | Error thrown as expected | | +| TC-fme_feature_flag-030 | Archive via Harness-native scope rejected | `harness_execute(resource_type="fme_feature_flag", action="archive", org_id="my_org", project_id="my_project", feature_flag_name="my_flag")` | Error: "not yet implemented for this operation" | ✅ Passed | Error thrown as expected | | +| TC-fme_feature_flag-031 | Unarchive via Harness-native scope rejected | `harness_execute(resource_type="fme_feature_flag", action="unarchive", org_id="my_org", project_id="my_project", feature_flag_name="my_flag")` | Error: "not yet implemented for this operation" | ✅ Passed | Error thrown as expected | | ## Summary | Metric | Count | |--------|-------| -| Total Tests | 13 | -| ✅ Passed | 0 | +| Total Tests | 31 | +| ✅ Passed | 9 | | ❌ Failed | 1 | -| ⚠️ Blocked | 0 | -| ⬜ Not Run | 12 | +| ⚠️ Blocked | 2 | +| ⬜ Not Run | 19 | ## Issues Found | Issue ID | Severity | Description | Test ID | Status | |----------|----------|-------------|---------|--------| +| FME-DUALMODE-500 | High | Harness-native `get`/`delete` for `fme_feature_flag` (and the corresponding wired-through operations on `fme_environment`, `fme_standard_segment`, `fme_rule_based_segment`) return HTTP 500 from the backend. MCP-side routing, path construction, and `product: "harness"` auth are confirmed correct — this is a backend-side gap, not an MCP defect. | TC-fme_feature_flag-024, TC-fme_feature_flag-025 | Open — tracked as a backend follow-up | ## Sample Responses _(To be filled during testing)_ diff --git a/src/prompts/feature-flag-rollout.ts b/src/prompts/feature-flag-rollout.ts index b365448f3..b366c23f2 100644 --- a/src/prompts/feature-flag-rollout.ts +++ b/src/prompts/feature-flag-rollout.ts @@ -8,22 +8,36 @@ export function registerFeatureFlagRolloutPrompt(server: McpServer): void { description: "Plan and execute a progressive FME feature flag rollout across environments", argsSchema: { featureFlagName: z.string().describe("Feature flag name to roll out"), - workspaceId: z.string().describe("FME workspace ID"), + workspaceId: z.string().describe("FME workspace ID (deprecated — omit if passing orgId+projectId)").optional(), + orgId: z.string().describe("Harness org identifier (pass together with projectId)").optional(), + projectId: z.string().describe("Harness project identifier (pass together with orgId)").optional(), }, }, - async ({ featureFlagName, workspaceId }) => { + async ({ featureFlagName, workspaceId, orgId, projectId }) => { + if (!workspaceId && !(orgId && projectId)) { + throw new Error("Provide either workspaceId (deprecated) or orgId + projectId."); + } + + const scopeArgs = workspaceId + ? `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, content: { type: "text" as const, - text: `Plan a progressive rollout for FME feature flag "${featureFlagName}" in workspace "${workspaceId}". + text: `Plan a progressive rollout for FME feature flag "${featureFlagName}" (${scopeArgs}). Steps: -1. **Get flag details**: Call harness_get with resource_type="fme_feature_flag", feature_flag_name="${featureFlagName}", workspace_id="${workspaceId}" to see the current flag state -2. **List environments**: Call harness_list with resource_type="fme_environment", workspace_id="${workspaceId}" to see available environments -3. **Get flag definition per environment**: For each environment, call harness_get with resource_type="fme_feature_flag_definition", feature_flag_name="${featureFlagName}", workspace_id="${workspaceId}", environment_id= to see treatments and rules -4. **Check rollout statuses**: Call harness_list with resource_type="fme_rollout_status", workspace_id="${workspaceId}" for rollout status context +1. **Get flag details**: Call harness_get with resource_type="fme_feature_flag", feature_flag_name="${featureFlagName}", ${scopeArgs} to see the current flag state +2. **List environments**: Call harness_list with resource_type="fme_environment", ${scopeArgs} to see available environments +3. **Get flag definition per environment**: For each environment, call harness_get with resource_type="fme_feature_flag_definition", feature_flag_name="${featureFlagName}", ${scopeArgs}, environment_id= to see treatments and rules +4. **Check rollout statuses**: Call harness_list with resource_type="fme_rollout_status", ${scopeArgs} for rollout status context 5. **Propose rollout plan**: Recommend a progressive rollout strategy: - Phase 1: Restore flag in dev/test environments - Phase 2: Restore in staging, verify treatments @@ -32,7 +46,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", workspace_id="${workspaceId}", feature_flag_name="${featureFlagName}", environment_id= to execute each phase after user approval.`, +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= to execute each phase after user approval.${nativeModeCaveat}`, }, }], }; diff --git a/src/registry/index.ts b/src/registry/index.ts index c71f12561..ac909486a 100644 --- a/src/registry/index.ts +++ b/src/registry/index.ts @@ -610,6 +610,8 @@ export class Registry { const explicitScopeValues = requestedScope ? getExplicitScopeValues(requestedScope, input, this.config) : undefined; const pathDefaultScope = requestedScope ?? def.scope; + const resolvedRoute = spec.routeResolver ? spec.routeResolver(input, resolvedConfig) : undefined; + // Run preflight hook (e.g. duplicate-check before create) before hitting the API. if (spec.preflight) { await spec.preflight({ client, input, registry: this, signal }); @@ -625,7 +627,9 @@ export class Registry { // Build path with substitutions (or pathBuilder when present) let path: string; - if (spec.pathBuilder) { + if (resolvedRoute) { + path = resolvedRoute.path; + } else if (spec.pathBuilder) { path = spec.pathBuilder(input, resolvedConfig); } else { path = spec.path; @@ -672,10 +676,16 @@ export class Registry { } } else if (def.scopeOptional) { // Dynamic scoping: only inject when caller explicitly provides them. - if (input.org_id) { + // A legacy workspace_id (FME's Split.io identifier) takes precedence over + // any org/project incidentally picked up from a UI URL — these are two + // mutually exclusive scoping modes for FME resources. The suppression is + // keyed on product: "fme" so a future non-FME scopeOptional resource with an + // unrelated field literally named `workspace_id` never loses its scope params. + const suppressForFmeWorkspace = def.product === "fme" && input.workspace_id !== undefined; + if (input.org_id && !suppressForFmeWorkspace) { params[orgParam] = input.org_id as string; } - if (input.project_id) { + if (input.project_id && !suppressForFmeWorkspace) { params[projectParam] = input.project_id as string; } } else { @@ -805,7 +815,7 @@ export class Registry { } // Make request — resolve base URL and auth from product backend - const product = def.product ?? "harness"; + const product = resolvedRoute?.product ?? def.product ?? "harness"; const baseUrl = resolveProductBaseUrl(this.config, product); const productHeaders: Record = { ...spec.headers }; @@ -820,7 +830,7 @@ export class Registry { ...(product !== "harness" ? { product } : {}), ...(spec.headerBasedScoping || def.headerBasedScoping ? { headerBasedScoping: true } : {}), ...(spec.operationPolicy?.retryPolicy ? { retryPolicy: spec.operationPolicy.retryPolicy } : {}), - ...(!spec.pathBuilder ? { tracing: { route: spec.path } } : {}), + ...(!spec.pathBuilder && !resolvedRoute ? { tracing: { route: spec.path } } : {}), signal, }; diff --git a/src/registry/scope-utils.ts b/src/registry/scope-utils.ts index 28c2e58ce..21d82eec5 100644 --- a/src/registry/scope-utils.ts +++ b/src/registry/scope-utils.ts @@ -42,3 +42,88 @@ export function templateV1BasePathFromScope( } return "/v1/templates"; } + +export type FmeDualModeResult = + | { mode: "legacy"; workspaceId: string } + | { mode: "harness_native"; orgId: string; projectId: string }; + +/** + * Detects whether FME call uses deprecated `workspace_id` contract or + * new Harness-native `org_id`+`project_id` contract. Shared by every + * `fme_*` resource's `routeResolver` in `feature-flags.ts`. + */ +export function resolveFmeDualMode(input: Record, resourceType: string): FmeDualModeResult { + const workspaceId = input.workspace_id as string | undefined; + const orgId = input.org_id as string | undefined; + const projectId = input.project_id as string | undefined; + + // Check for mixing deprecated and new approaches + if (workspaceId && (orgId || projectId)) { + throw new Error( + `${resourceType}: pass either workspace_id (deprecated) OR org_id+project_id, not both.`, + ); + } + + // Handle legacy workspace_id mode + if (workspaceId) { + console.error( + `[DEPRECATION] ${resourceType}: workspace_id-based FME calls are deprecated — pass org_id+project_id instead.`, + ); + return { mode: "legacy", workspaceId }; + } + + // Handle Harness-native mode + if (orgId && projectId) { + return { mode: "harness_native", orgId, projectId }; + } + + // If neither mode is satisfied, throw + throw new Error( + `${resourceType}: org_id and project_id are required (account is taken from config), or pass the deprecated workspace_id instead.`, + ); +} + +/** + * Validates that an FME identifier the caller must supply is actually present. + * + * FME `routeResolver`s build their own paths, which bypasses the registry's + * `pathParams` presence check — without this guard a missing identifier would + * silently produce a malformed URL (e.g. a trailing-slash DELETE) instead of a + * clear error. Returns the raw value; callers still encode it. + */ +export function requireFmeIdentifier(input: Record, field: string, resourceType: string): string { + const value = input[field]; + if (value === undefined || value === null || value === "") { + throw new Error(`${resourceType}: "${field}" is required.`); + } + return String(value); +} + +/** + * Mode selector for FME operations that have no Harness-native implementation yet + * and therefore cannot use `resolveFmeDualMode`. Returns true when the caller + * selected the Harness-native contract (org_id+project_id), false for the legacy + * contract. A partial pair is rejected: half a scope would otherwise leak a stray + * orgIdentifier/projectIdentifier query param onto a legacy Split.io API call. + */ +export function isFmeHarnessNativeSelected(input: Record, resourceType: string): boolean { + const orgId = input.org_id; + const projectId = input.project_id; + if (orgId && !projectId) { + throw new Error(`${resourceType}: project_id is required when org_id is provided.`); + } + if (projectId && !orgId) { + throw new Error(`${resourceType}: org_id is required when project_id is provided.`); + } + return Boolean(orgId && projectId); +} + +/** + * Toolset files may not call `console.*` directly (see architecture.test.ts — + * logging belongs in handlers/registry, not toolsets). Route deprecation + * logging that doesn't go through `resolveFmeDualMode` (e.g. permissive + * mode-selector resolvers) through here instead. + */ +export function logFmeDeprecation(message: string): void { + console.error(message); +} diff --git a/src/registry/toolsets/feature-flags.ts b/src/registry/toolsets/feature-flags.ts index 9bceb7767..f3413745f 100644 --- a/src/registry/toolsets/feature-flags.ts +++ b/src/registry/toolsets/feature-flags.ts @@ -1,5 +1,6 @@ import type { ToolsetDefinition, BodySchema } from "../types.js"; import { passthrough, fmeListExtract, fmeGetExtract } from "../extractors.js"; +import { isFmeHarnessNativeSelected, logFmeDeprecation, requireFmeIdentifier, resolveFmeDualMode } from "../scope-utils.js"; const fmeActionExtract = (raw: unknown) => { if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) return raw; @@ -118,6 +119,7 @@ export const featureFlagsToolset: ToolsetDefinition = { description: "Feature Management workspace. Supports list with pagination (offset/size, default 20, max 1000).", toolset: "feature-flags", scope: "account", + scopeOptional: true, identifierFields: ["workspace_id"], product: "fme", listFilterFields: [ @@ -127,6 +129,17 @@ export const featureFlagsToolset: ToolsetDefinition = { list: { method: "GET", path: "/internal/api/v2/workspaces", + routeResolver: (input) => { + if (isFmeHarnessNativeSelected(input, "fme_workspace.list")) { + throw new Error( + "fme_workspace: this resource has no Harness-native equivalent — it exists only to discover workspace_id values for the deprecated legacy contract.", + ); + } + logFmeDeprecation( + "[DEPRECATION] fme_workspace: this resource and the workspace_id contract it supports are deprecated.", + ); + return { path: "/internal/api/v2/workspaces" }; + }, operationPolicy: { risk: "read", retryPolicy: "safe" }, queryParams: { offset: "offset", @@ -143,18 +156,24 @@ export const featureFlagsToolset: ToolsetDefinition = { description: "Feature Management environment. Supports list. Requires a workspace_id.", toolset: "feature-flags", scope: "account", + scopeOptional: true, identifierFields: ["workspace_id", "environment_id"], product: "fme", - baseUrlOverride: "fme", listFilterFields: [ - { name: "workspace_id", description: "FME workspace ID (get from harness_list resource_type=fme_workspace)", required: true }, + { 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." }, ], operations: { list: { method: "GET", - path: "/internal/api/v2/environments/ws/{wsId}", + path: "", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_environment"); + if (mode.mode === "legacy") { + return { path: `/internal/api/v2/environments/ws/${encodeURIComponent(mode.workspaceId)}` }; + } + return { path: "/fme/internal/api/v4/environments", product: "harness" }; + }, operationPolicy: { risk: "read", retryPolicy: "safe" }, - pathParams: { workspace_id: "wsId" }, responseExtractor: passthrough, description: "List FME environments for a workspace", }, @@ -167,11 +186,12 @@ export const featureFlagsToolset: ToolsetDefinition = { "Feature flag via the Split.io API. List flags by workspace with filtering (name, tags, rollout_status_id) and pagination (offset/size, default 20, max 50). Supports create (requires traffic_type_id), get, delete, update, and kill/restore/archive/unarchive execute actions.", toolset: "feature-flags", scope: "account", + scopeOptional: true, identifierFields: ["workspace_id", "feature_flag_name"], product: "fme", deepLinkTemplate: "/ng/account/{accountId}/module/fme/orgs/{orgIdentifier}/projects/{projectIdentifier}/setup/resources/targets/{trafficTypeId}/splits/{id}", listFilterFields: [ - { name: "workspace_id", description: "FME workspace ID (get from harness_list resource_type=fme_workspace)", required: true }, + { 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: "Pagination offset for FME feature flags", type: "number" }, { name: "rollout_status_id", description: "Filter by rollout status UUID (use fme_rollout_status to discover valid IDs)", type: "string" }, { name: "name", description: "Filter flags by name (partial match)", type: "string" }, @@ -180,9 +200,15 @@ export const featureFlagsToolset: ToolsetDefinition = { operations: { list: { method: "GET", - path: "/internal/api/v2/splits/ws/{wsId}", + path: "", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_feature_flag"); + if (mode.mode === "legacy") { + return { path: `/internal/api/v2/splits/ws/${encodeURIComponent(mode.workspaceId)}` }; + } + return { path: "/fme/internal/api/v4/feature-flags", product: "harness" }; + }, operationPolicy: { risk: "read", retryPolicy: "safe" }, - pathParams: { workspace_id: "wsId" }, queryParams: { offset: "offset", size: "limit", @@ -192,19 +218,35 @@ export const featureFlagsToolset: ToolsetDefinition = { }, responseExtractor: fmeListExtract, description: - "List feature flags for a workspace with filtering and pagination (offset and size params, max 50).", + "List feature flags by workspace_id (legacy, deprecated) or org_id+project_id (Harness-native), with filtering and pagination (offset and size params, max 50).", }, get: { method: "GET", - path: "/internal/api/v2/splits/ws/{wsId}/{featureFlagName}", + path: "", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_feature_flag"); + const flagName = encodeURIComponent(requireFmeIdentifier(input, "feature_flag_name", "fme_feature_flag")); + if (mode.mode === "legacy") { + return { path: `/internal/api/v2/splits/ws/${encodeURIComponent(mode.workspaceId)}/${flagName}` }; + } + return { path: `/fme/internal/api/v4/feature-flags/${flagName}`, product: "harness" }; + }, operationPolicy: { risk: "read", retryPolicy: "safe" }, - pathParams: { workspace_id: "wsId", feature_flag_name: "featureFlagName" }, responseExtractor: fmeGetExtract, - description: "Get a specific feature flag's metadata without requiring an environment", + description: "Get a specific feature flag's metadata without requiring an environment (legacy: workspace_id; Harness-native: org_id+project_id).", }, create: { method: "POST", path: "/internal/api/v2/splits/ws/{wsId}/trafficTypes/{trafficTypeId}", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_feature_flag"); + if (mode.mode === "harness_native") { + throw new Error( + "fme_feature_flag.create: Harness-native (org_id/project_id) mode is not yet implemented for this operation — pass workspace_id (deprecated) instead.", + ); + } + return { path: `/internal/api/v2/splits/ws/${encodeURIComponent(mode.workspaceId)}/trafficTypes/${encodeURIComponent(requireFmeIdentifier(input, "traffic_type_id", "fme_feature_flag"))}` }; + }, operationPolicy: { risk: "low_write", retryPolicy: "do_not_retry" }, pathParams: { workspace_id: "wsId", traffic_type_id: "trafficTypeId" }, bodyBuilder: (input) => { @@ -220,15 +262,32 @@ export const featureFlagsToolset: ToolsetDefinition = { }, delete: { method: "DELETE", - path: "/internal/api/v2/splits/ws/{wsId}/{featureFlagName}", + path: "", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_feature_flag"); + const flagName = encodeURIComponent(requireFmeIdentifier(input, "feature_flag_name", "fme_feature_flag")); + if (mode.mode === "legacy") { + return { path: `/internal/api/v2/splits/ws/${encodeURIComponent(mode.workspaceId)}/${flagName}` }; + } + return { path: `/fme/internal/api/v4/feature-flags/${flagName}`, product: "harness" }; + }, operationPolicy: { risk: "destructive", retryPolicy: "do_not_retry" }, - pathParams: { workspace_id: "wsId", feature_flag_name: "featureFlagName" }, responseExtractor: passthrough, - description: "Delete a feature flag from a workspace", + description: "Delete a feature flag from a workspace (legacy) or org_id+project_id-scoped project (Harness-native)", }, update: { method: "PATCH", path: "/internal/api/v2/splits/ws/{wsId}/{featureFlagName}", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_feature_flag"); + if (mode.mode === "harness_native") { + throw new Error( + "fme_feature_flag.update: Harness-native (org_id/project_id) mode is not yet implemented for this operation — pass workspace_id (deprecated) instead.", + ); + } + const flagName = encodeURIComponent(requireFmeIdentifier(input, "feature_flag_name", "fme_feature_flag")); + return { path: `/internal/api/v2/splits/ws/${encodeURIComponent(mode.workspaceId)}/${flagName}` }; + }, operationPolicy: { risk: "low_write", retryPolicy: "safe" }, pathParams: { workspace_id: "wsId", feature_flag_name: "featureFlagName" }, bodyBuilder: (input) => { @@ -262,6 +321,17 @@ export const featureFlagsToolset: ToolsetDefinition = { kill: { method: "PUT", path: "/internal/api/v2/splits/ws/{wsId}/{featureFlagName}/environments/{environmentId}/kill", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_feature_flag"); + if (mode.mode === "harness_native") { + throw new Error( + "fme_feature_flag.kill: Harness-native (org_id/project_id) mode is not yet implemented for this operation — pass workspace_id (deprecated) instead.", + ); + } + const flagName = encodeURIComponent(requireFmeIdentifier(input, "feature_flag_name", "fme_feature_flag")); + const environmentId = encodeURIComponent(requireFmeIdentifier(input, "environment_id", "fme_feature_flag")); + return { path: `/internal/api/v2/splits/ws/${encodeURIComponent(mode.workspaceId)}/${flagName}/environments/${environmentId}/kill` }; + }, operationPolicy: { risk: "high_write", retryPolicy: "do_not_retry" }, pathParams: { workspace_id: "wsId", @@ -279,6 +349,17 @@ export const featureFlagsToolset: ToolsetDefinition = { restore: { method: "PUT", path: "/internal/api/v2/splits/ws/{wsId}/{featureFlagName}/environments/{environmentId}/restore", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_feature_flag"); + if (mode.mode === "harness_native") { + throw new Error( + "fme_feature_flag.restore: Harness-native (org_id/project_id) mode is not yet implemented for this operation — pass workspace_id (deprecated) instead.", + ); + } + const flagName = encodeURIComponent(requireFmeIdentifier(input, "feature_flag_name", "fme_feature_flag")); + const environmentId = encodeURIComponent(requireFmeIdentifier(input, "environment_id", "fme_feature_flag")); + return { path: `/internal/api/v2/splits/ws/${encodeURIComponent(mode.workspaceId)}/${flagName}/environments/${environmentId}/restore` }; + }, operationPolicy: { risk: "high_write", retryPolicy: "do_not_retry" }, pathParams: { workspace_id: "wsId", @@ -296,6 +377,16 @@ export const featureFlagsToolset: ToolsetDefinition = { archive: { method: "POST", path: "/internal/api/v2/splits/ws/{wsId}/{featureFlagName}/archive", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_feature_flag"); + if (mode.mode === "harness_native") { + throw new Error( + "fme_feature_flag.archive: Harness-native (org_id/project_id) mode is not yet implemented for this operation — pass workspace_id (deprecated) instead.", + ); + } + const flagName = encodeURIComponent(requireFmeIdentifier(input, "feature_flag_name", "fme_feature_flag")); + return { path: `/internal/api/v2/splits/ws/${encodeURIComponent(mode.workspaceId)}/${flagName}/archive` }; + }, operationPolicy: { risk: "high_write", retryPolicy: "do_not_retry" }, pathParams: { workspace_id: "wsId", feature_flag_name: "featureFlagName" }, bodyBuilder: () => ({}), @@ -309,6 +400,16 @@ export const featureFlagsToolset: ToolsetDefinition = { unarchive: { method: "POST", path: "/internal/api/v2/splits/ws/{wsId}/{featureFlagName}/unarchive", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_feature_flag"); + if (mode.mode === "harness_native") { + throw new Error( + "fme_feature_flag.unarchive: Harness-native (org_id/project_id) mode is not yet implemented for this operation — pass workspace_id (deprecated) instead.", + ); + } + const flagName = encodeURIComponent(requireFmeIdentifier(input, "feature_flag_name", "fme_feature_flag")); + return { path: `/internal/api/v2/splits/ws/${encodeURIComponent(mode.workspaceId)}/${flagName}/unarchive` }; + }, operationPolicy: { risk: "high_write", retryPolicy: "do_not_retry" }, pathParams: { workspace_id: "wsId", feature_flag_name: "featureFlagName" }, bodyBuilder: () => ({}), @@ -328,12 +429,24 @@ export const featureFlagsToolset: ToolsetDefinition = { "Detailed definition of a feature flag in a specific environment, including treatments, rules, targeting, and traffic allocation. Supports create, get, and update. Create requires treatments, defaultTreatment, and defaultRule.", toolset: "feature-flags", scope: "account", + scopeOptional: true, identifierFields: ["workspace_id", "environment_id", "feature_flag_name"], product: "fme", operations: { get: { method: "GET", path: "/internal/api/v2/splits/ws/{wsId}/{featureFlagName}/environments/{environmentId}", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_feature_flag_definition"); + if (mode.mode === "harness_native") { + throw new Error( + "fme_feature_flag_definition.get: Harness-native (org_id/project_id) mode not yet implemented for this operation — pass workspace_id (deprecated) instead.", + ); + } + const flagName = encodeURIComponent(requireFmeIdentifier(input, "feature_flag_name", "fme_feature_flag_definition")); + const environmentId = encodeURIComponent(requireFmeIdentifier(input, "environment_id", "fme_feature_flag_definition")); + return { path: `/internal/api/v2/splits/ws/${encodeURIComponent(mode.workspaceId)}/${flagName}/environments/${environmentId}` }; + }, operationPolicy: { risk: "read", retryPolicy: "safe" }, pathParams: { workspace_id: "wsId", @@ -346,6 +459,17 @@ export const featureFlagsToolset: ToolsetDefinition = { create: { method: "POST", path: "/internal/api/v2/splits/ws/{wsId}/{featureFlagName}/environments/{environmentId}", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_feature_flag_definition"); + if (mode.mode === "harness_native") { + throw new Error( + "fme_feature_flag_definition.create: Harness-native (org_id/project_id) mode not yet implemented for this operation — pass workspace_id (deprecated) instead.", + ); + } + const flagName = encodeURIComponent(requireFmeIdentifier(input, "feature_flag_name", "fme_feature_flag_definition")); + const environmentId = encodeURIComponent(requireFmeIdentifier(input, "environment_id", "fme_feature_flag_definition")); + return { path: `/internal/api/v2/splits/ws/${encodeURIComponent(mode.workspaceId)}/${flagName}/environments/${environmentId}` }; + }, operationPolicy: { risk: "low_write", retryPolicy: "do_not_retry" }, pathParams: { workspace_id: "wsId", @@ -360,6 +484,17 @@ export const featureFlagsToolset: ToolsetDefinition = { update: { method: "PUT", path: "/internal/api/v2/splits/ws/{wsId}/{featureFlagName}/environments/{environmentId}", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_feature_flag_definition"); + if (mode.mode === "harness_native") { + throw new Error( + "fme_feature_flag_definition.update: Harness-native (org_id/project_id) mode not yet implemented for this operation — pass workspace_id (deprecated) instead.", + ); + } + const flagName = encodeURIComponent(requireFmeIdentifier(input, "feature_flag_name", "fme_feature_flag_definition")); + const environmentId = encodeURIComponent(requireFmeIdentifier(input, "environment_id", "fme_feature_flag_definition")); + return { path: `/internal/api/v2/splits/ws/${encodeURIComponent(mode.workspaceId)}/${flagName}/environments/${environmentId}` }; + }, operationPolicy: { risk: "low_write", retryPolicy: "safe" }, pathParams: { workspace_id: "wsId", @@ -380,15 +515,25 @@ export const featureFlagsToolset: ToolsetDefinition = { "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.", toolset: "feature-flags", scope: "account", + scopeOptional: true, identifierFields: ["workspace_id"], product: "fme", listFilterFields: [ - { name: "workspace_id", description: "FME workspace ID (get from fme_workspace)", required: true }, + { name: "workspace_id", description: "FME workspace ID (get from fme_workspace). Deprecated — omit and pass org_id+project_id instead for Harness-native scoping." }, ], operations: { list: { method: "GET", path: "/internal/api/v2/rolloutStatuses/ws/{wsId}", + 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.", + ); + } + return { path: `/internal/api/v2/rolloutStatuses/ws/${encodeURIComponent(mode.workspaceId)}` }; + }, operationPolicy: { risk: "read", retryPolicy: "safe" }, pathParams: { workspace_id: "wsId" }, responseExtractor: passthrough, @@ -404,33 +549,55 @@ export const featureFlagsToolset: ToolsetDefinition = { "Rule-based segment in a workspace. Supports list, get, create (requires traffic_type_id), and delete. Create requires traffic_type_id passed via params.", toolset: "feature-flags", scope: "account", + scopeOptional: true, identifierFields: ["workspace_id", "segment_name"], product: "fme", listFilterFields: [ - { name: "workspace_id", description: "FME workspace ID (get from fme_workspace)", required: true }, + { name: "workspace_id", description: "FME workspace ID (get from fme_workspace). Deprecated — omit and pass org_id+project_id instead for Harness-native scoping." }, ], operations: { list: { method: "GET", - path: "/internal/api/v2/rule-based-segments/ws/{wsId}", + path: "", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_rule_based_segment"); + if (mode.mode === "legacy") { + return { path: `/internal/api/v2/rule-based-segments/ws/${encodeURIComponent(mode.workspaceId)}` }; + } + return { path: "/fme/internal/api/v4/segments", product: "harness" }; + }, operationPolicy: { risk: "read", retryPolicy: "safe" }, - pathParams: { workspace_id: "wsId" }, responseExtractor: passthrough, description: "List all rule-based segments in a workspace", }, get: { method: "GET", - path: "/internal/api/v2/rule-based-segments/ws/{wsId}/{rbSegmentName}", + path: "", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_rule_based_segment"); + const segmentName = encodeURIComponent(requireFmeIdentifier(input, "segment_name", "fme_rule_based_segment")); + if (mode.mode === "legacy") { + return { path: `/internal/api/v2/rule-based-segments/ws/${encodeURIComponent(mode.workspaceId)}/${segmentName}` }; + } + return { path: `/fme/internal/api/v4/segments/${segmentName}`, product: "harness" }; + }, operationPolicy: { risk: "read", retryPolicy: "safe" }, - pathParams: { workspace_id: "wsId", segment_name: "rbSegmentName" }, responseExtractor: passthrough, description: "Get a rule-based segment by name (workspace-level metadata)", }, create: { method: "POST", - path: "/internal/api/v2/rule-based-segments/ws/{wsId}/trafficTypes/{trafficTypeId}", + path: "", + routeResolver: (input) => { + 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 not yet implemented for this operation — pass workspace_id (deprecated) instead.", + ); + } + return { path: `/internal/api/v2/rule-based-segments/ws/${encodeURIComponent(mode.workspaceId)}/trafficTypes/${encodeURIComponent(requireFmeIdentifier(input, "traffic_type_id", "fme_rule_based_segment"))}` }; + }, operationPolicy: { risk: "low_write", retryPolicy: "do_not_retry" }, - pathParams: { workspace_id: "wsId", traffic_type_id: "trafficTypeId" }, bodyBuilder: (input) => { const body = input.body as Record | undefined; return { @@ -444,9 +611,16 @@ export const featureFlagsToolset: ToolsetDefinition = { }, delete: { method: "DELETE", - path: "/internal/api/v2/rule-based-segments/ws/{wsId}/{rbSegmentName}", + path: "", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_rule_based_segment"); + const segmentName = encodeURIComponent(requireFmeIdentifier(input, "segment_name", "fme_rule_based_segment")); + if (mode.mode === "legacy") { + return { path: `/internal/api/v2/rule-based-segments/ws/${encodeURIComponent(mode.workspaceId)}/${segmentName}` }; + } + return { path: `/fme/internal/api/v4/segments/${segmentName}`, product: "harness" }; + }, operationPolicy: { risk: "destructive", retryPolicy: "do_not_retry" }, - pathParams: { workspace_id: "wsId", segment_name: "rbSegmentName" }, responseExtractor: passthrough, description: "Delete a rule-based segment from a workspace. Environment-level configs must be removed separately.", }, @@ -459,16 +633,27 @@ export const featureFlagsToolset: ToolsetDefinition = { "Environment-specific definition of a rule-based segment, including targeting rules, exclusions, and matchers. Supports list (by environment), update, and enable/disable/change_request execute actions.", toolset: "feature-flags", scope: "account", + scopeOptional: true, identifierFields: ["workspace_id", "environment_id", "segment_name"], product: "fme", listFilterFields: [ - { name: "workspace_id", description: "FME workspace ID (get from fme_workspace)", required: true }, + { 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: "environment_id", description: "FME environment ID (get from fme_environment)", required: true }, ], operations: { list: { method: "GET", path: "/internal/api/v2/rule-based-segments/ws/{wsId}/environments/{environmentId}", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_rule_based_segment_definition"); + if (mode.mode === "harness_native") { + throw new Error( + "fme_rule_based_segment_definition.list: Harness-native (org_id/project_id) mode not yet implemented for this operation — pass workspace_id (deprecated) instead.", + ); + } + const environmentId = encodeURIComponent(requireFmeIdentifier(input, "environment_id", "fme_rule_based_segment_definition")); + return { path: `/internal/api/v2/rule-based-segments/ws/${encodeURIComponent(mode.workspaceId)}/environments/${environmentId}` }; + }, operationPolicy: { risk: "read", retryPolicy: "safe" }, pathParams: { workspace_id: "wsId", environment_id: "environmentId" }, responseExtractor: passthrough, @@ -477,6 +662,17 @@ export const featureFlagsToolset: ToolsetDefinition = { update: { method: "PUT", path: "/internal/api/v2/rule-based-segments/ws/{wsId}/{rbSegmentName}/environments/{environmentId}", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_rule_based_segment_definition"); + if (mode.mode === "harness_native") { + throw new Error( + "fme_rule_based_segment_definition.update: Harness-native (org_id/project_id) mode not yet implemented for this operation — pass workspace_id (deprecated) instead.", + ); + } + const segmentName = encodeURIComponent(requireFmeIdentifier(input, "segment_name", "fme_rule_based_segment_definition")); + const environmentId = encodeURIComponent(requireFmeIdentifier(input, "environment_id", "fme_rule_based_segment_definition")); + return { path: `/internal/api/v2/rule-based-segments/ws/${encodeURIComponent(mode.workspaceId)}/${segmentName}/environments/${environmentId}` }; + }, operationPolicy: { risk: "low_write", retryPolicy: "safe" }, pathParams: { workspace_id: "wsId", segment_name: "rbSegmentName", environment_id: "environmentId" }, bodyBuilder: (input) => input.body, @@ -489,6 +685,16 @@ export const featureFlagsToolset: ToolsetDefinition = { enable: { method: "POST", path: "/internal/api/v2/rule-based-segments/{environmentId}/{rbSegmentName}", + routeResolver: (input) => { + if (isFmeHarnessNativeSelected(input, "fme_rule_based_segment_definition.enable")) { + throw new Error( + "fme_rule_based_segment_definition.enable: 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_rule_based_segment_definition")); + const segmentName = encodeURIComponent(requireFmeIdentifier(input, "segment_name", "fme_rule_based_segment_definition")); + return { path: `/internal/api/v2/rule-based-segments/${environmentId}/${segmentName}` }; + }, operationPolicy: { risk: "medium_write", retryPolicy: "do_not_retry" }, pathParams: { environment_id: "environmentId", segment_name: "rbSegmentName" }, bodyBuilder: () => ({}), @@ -499,6 +705,16 @@ export const featureFlagsToolset: ToolsetDefinition = { disable: { method: "DELETE", path: "/internal/api/v2/rule-based-segments/{environmentId}/{rbSegmentName}", + routeResolver: (input) => { + if (isFmeHarnessNativeSelected(input, "fme_rule_based_segment_definition.disable")) { + throw new Error( + "fme_rule_based_segment_definition.disable: 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_rule_based_segment_definition")); + const segmentName = encodeURIComponent(requireFmeIdentifier(input, "segment_name", "fme_rule_based_segment_definition")); + return { path: `/internal/api/v2/rule-based-segments/${environmentId}/${segmentName}` }; + }, operationPolicy: { risk: "medium_write", retryPolicy: "do_not_retry" }, pathParams: { environment_id: "environmentId", segment_name: "rbSegmentName" }, responseExtractor: passthrough, @@ -511,6 +727,16 @@ export const featureFlagsToolset: ToolsetDefinition = { change_request: { method: "POST", path: "/internal/api/v2/changeRequests/ws/{wsId}/environments/{environmentId}", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_rule_based_segment_definition"); + if (mode.mode === "harness_native") { + throw new Error( + "fme_rule_based_segment_definition.change_request: Harness-native (org_id/project_id) mode not yet implemented for this operation — pass workspace_id (deprecated) instead.", + ); + } + const environmentId = encodeURIComponent(requireFmeIdentifier(input, "environment_id", "fme_rule_based_segment_definition")); + return { path: `/internal/api/v2/changeRequests/ws/${encodeURIComponent(mode.workspaceId)}/environments/${environmentId}` }; + }, operationPolicy: { risk: "medium_write", retryPolicy: "do_not_retry" }, pathParams: { workspace_id: "wsId", environment_id: "environmentId" }, bodyBuilder: (input) => ({ @@ -534,15 +760,25 @@ export const featureFlagsToolset: ToolsetDefinition = { "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.", toolset: "feature-flags", scope: "account", + scopeOptional: true, identifierFields: ["workspace_id"], product: "fme", listFilterFields: [ - { name: "workspace_id", description: "FME workspace ID (get from fme_workspace)", required: true }, + { name: "workspace_id", description: "FME workspace ID (get from fme_workspace). Deprecated — omit and pass org_id+project_id instead for Harness-native scoping." }, ], operations: { list: { method: "GET", path: "/internal/api/v2/trafficTypes/ws/{wsId}", + 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.", + ); + } + return { path: `/internal/api/v2/trafficTypes/ws/${encodeURIComponent(mode.workspaceId)}` }; + }, operationPolicy: { risk: "read", retryPolicy: "safe" }, pathParams: { workspace_id: "wsId" }, responseExtractor: passthrough, @@ -558,14 +794,31 @@ export const featureFlagsToolset: ToolsetDefinition = { "Identity (target) in an environment. Create or update identities to manage display name aliases and custom attributes. Requires traffic_type_id and environment_id. Note: the Split Admin API does not support listing or getting individual identities — use create (batch upsert) and update (PATCH single key).", toolset: "feature-flags", scope: "account", + scopeOptional: true, identifierFields: ["traffic_type_id", "environment_id", "key"], product: "fme", operations: { create: { method: "POST", path: "/internal/api/v2/trafficTypes/{trafficTypeId}/environments/{environmentId}/identities", + routeResolver: (input) => { + if (isFmeHarnessNativeSelected(input, "fme_identity.create")) { + throw new Error( + "fme_identity.create: Harness-native (org_id/project_id) mode not yet implemented for this operation — pass traffic_type_id/environment_id (current contract) instead.", + ); + } + const trafficTypeId = encodeURIComponent(requireFmeIdentifier(input, "traffic_type_id", "fme_identity")); + const environmentId = encodeURIComponent(requireFmeIdentifier(input, "environment_id", "fme_identity")); + return { path: `/internal/api/v2/trafficTypes/${trafficTypeId}/environments/${environmentId}/identities` }; + }, operationPolicy: { risk: "low_write", retryPolicy: "safe" }, pathParams: { traffic_type_id: "trafficTypeId", environment_id: "environmentId" }, + paramsSchema: { + fields: [ + { name: "org_id", required: false, description: "Optional — pass together with project_id to select the (not yet implemented) Harness-native mode instead of the current contract." }, + { name: "project_id", required: false, description: "Optional — pass together with org_id to select the (not yet implemented) Harness-native mode instead of the current contract." }, + ], + }, skipScopeBodyInjection: true, bodyBuilder: (input) => { const body = input.body; @@ -590,8 +843,25 @@ export const featureFlagsToolset: ToolsetDefinition = { update: { method: "PATCH", path: "/internal/api/v2/trafficTypes/{trafficTypeId}/environments/{environmentId}/identities/{key}", + routeResolver: (input) => { + if (isFmeHarnessNativeSelected(input, "fme_identity.update")) { + throw new Error( + "fme_identity.update: Harness-native (org_id/project_id) mode not yet implemented for this operation — pass traffic_type_id/environment_id (current contract) instead.", + ); + } + const trafficTypeId = encodeURIComponent(requireFmeIdentifier(input, "traffic_type_id", "fme_identity")); + const environmentId = encodeURIComponent(requireFmeIdentifier(input, "environment_id", "fme_identity")); + const key = encodeURIComponent(requireFmeIdentifier(input, "key", "fme_identity")); + return { path: `/internal/api/v2/trafficTypes/${trafficTypeId}/environments/${environmentId}/identities/${key}` }; + }, operationPolicy: { risk: "low_write", retryPolicy: "safe" }, pathParams: { traffic_type_id: "trafficTypeId", environment_id: "environmentId", key: "key" }, + paramsSchema: { + fields: [ + { name: "org_id", required: false, description: "Optional — pass together with project_id to select the (not yet implemented) Harness-native mode instead of the current contract." }, + { name: "project_id", required: false, description: "Optional — pass together with org_id to select the (not yet implemented) Harness-native mode instead of the current contract." }, + ], + }, bodyBuilder: (input) => input.body, responseExtractor: passthrough, bodySchema: fmeIdentityUpdateSchema, @@ -607,25 +877,39 @@ export const featureFlagsToolset: ToolsetDefinition = { "Standard (static list) segment in a workspace. List all segments to see names, descriptions, and member counts. For member management, use fme_segment_keys.", toolset: "feature-flags", scope: "account", + scopeOptional: true, identifierFields: ["workspace_id", "segment_name"], product: "fme", listFilterFields: [ - { name: "workspace_id", description: "Workspace ID (get from fme_workspace)", required: true }, + { name: "workspace_id", description: "Workspace ID (get from fme_workspace). Deprecated — omit and pass org_id+project_id instead for Harness-native scoping." }, ], operations: { list: { method: "GET", - path: "/internal/api/v2/segments/ws/{wsId}", + path: "", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_standard_segment"); + if (mode.mode === "legacy") { + return { path: `/internal/api/v2/segments/ws/${encodeURIComponent(mode.workspaceId)}` }; + } + return { path: "/fme/internal/api/v4/segments", product: "harness" }; + }, operationPolicy: { risk: "read", retryPolicy: "safe" }, - pathParams: { workspace_id: "wsId" }, responseExtractor: passthrough, description: "List all standard segments in a workspace. Returns segment name, description, and creation metadata.", }, get: { method: "GET", - path: "/internal/api/v2/segments/ws/{wsId}/{segmentName}", + path: "", + routeResolver: (input) => { + const mode = resolveFmeDualMode(input, "fme_standard_segment"); + const segmentName = encodeURIComponent(requireFmeIdentifier(input, "segment_name", "fme_standard_segment")); + if (mode.mode === "legacy") { + return { path: `/internal/api/v2/segments/ws/${encodeURIComponent(mode.workspaceId)}/${segmentName}` }; + } + return { path: `/fme/internal/api/v4/segments/${segmentName}`, product: "harness" }; + }, operationPolicy: { risk: "read", retryPolicy: "safe" }, - pathParams: { workspace_id: "wsId", segment_name: "segmentName" }, responseExtractor: passthrough, description: "Get a standard segment's metadata by name.", }, @@ -638,17 +922,30 @@ export const featureFlagsToolset: ToolsetDefinition = { "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, identifierFields: ["environment_id", "segment_name"], product: "fme", listFilterFields: [ { 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: { method: "GET", 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.", + ); + } + const environmentId = encodeURIComponent(requireFmeIdentifier(input, "environment_id", "fme_segment_keys")); + const segmentName = encodeURIComponent(requireFmeIdentifier(input, "segment_name", "fme_segment_keys")); + return { path: `/internal/api/v2/segments/${environmentId}/${segmentName}/keys` }; + }, operationPolicy: { risk: "read", retryPolicy: "safe" }, pathParams: { environment_id: "environmentId", segment_name: "segmentName" }, queryParams: { @@ -661,6 +958,16 @@ export const featureFlagsToolset: ToolsetDefinition = { update: { method: "PUT", 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.", + ); + } + const environmentId = encodeURIComponent(requireFmeIdentifier(input, "environment_id", "fme_segment_keys")); + const segmentName = encodeURIComponent(requireFmeIdentifier(input, "segment_name", "fme_segment_keys")); + return { path: `/internal/api/v2/segments/${environmentId}/${segmentName}/upload` }; + }, operationPolicy: { risk: "medium_write", retryPolicy: "do_not_retry" }, pathParams: { environment_id: "environmentId", segment_name: "segmentName" }, skipScopeBodyInjection: true, diff --git a/src/registry/types.ts b/src/registry/types.ts index b116e4dc1..d31362633 100644 --- a/src/registry/types.ts +++ b/src/registry/types.ts @@ -242,6 +242,17 @@ export interface InputExpansionRule { */ export type PathBuilderConfig = { HARNESS_ACCOUNT_ID?: string; HARNESS_ORG?: string; HARNESS_PROJECT?: string }; +/** + * Output of `EndpointSpec.routeResolver`: fully-resolved path for a specific call, + * plus optional product override. When `product` is omitted, the resource-level + * `def.product` applies. Enables dual-mode routing (e.g. FME legacy Split.io + * vs. Harness-native based on caller-supplied params). + */ +export interface ResolvedRoute { + path: string; + product?: ProductName; +} + /** * Specifies how a single CRUD operation maps to the Harness API. */ @@ -253,6 +264,15 @@ export interface EndpointSpec { path: string; /** Optional dynamic path builder. When set, used instead of path + pathParams for account-scoped or multi-endpoint resources. */ pathBuilder?: (input: Record, config: PathBuilderConfig) => string; + /** + * Optional per-call route resolver. When set, its output supersedes BOTH + * `path`/`pathParams`/`pathBuilder` on this endpoint AND the resource-level + * `product` on the call. Use when a single resourceType has multiple route paths + * AND different backend products depending on caller-supplied params + * (e.g. FME dual-mode: legacy Split.io vs. Harness-native). Purely additive; + * resources that don't set it are completely unaffected. + */ + routeResolver?: (input: Record, config: PathBuilderConfig) => ResolvedRoute; /** Maps tool input field names to path param placeholders */ pathParams?: Record; /** Maps tool input field names to query param names */ @@ -428,7 +448,11 @@ export interface ResourceDefinition { executeActions?: Record; /** * Product backend for this resource. Defaults to "harness" (uses HARNESS_BASE_URL). - * Set to "fme" to use the Split.io API at https://api.split.io. + * @deprecated The "fme" value (Split.io API at https://api.split.io) is legacy-only, + * used solely by FME resources' deprecated workspace_id-mode calls (see + * `routeResolver` on `EndpointSpec` and `resolveFmeDualMode` in scope-utils.ts). + * New code should not introduce new "fme"-product resources — FME itself is + * migrating to plain Harness-native ("harness") routing per-call. */ product?: ProductName; baseUrlOverride?: "fme"; diff --git a/src/tools/harness-create.ts b/src/tools/harness-create.ts index a2d155a5e..ea8d2d85b 100644 --- a/src/tools/harness-create.ts +++ b/src/tools/harness-create.ts @@ -45,8 +45,12 @@ export function registerCreateTool(server: McpServer, registry: Registry, client try { const { params, body, confirm: _confirm, ...rest } = args; const coercedBody = typeof body === "string" ? (coerceRecord(body) ?? body) : body; - const input = applyUrlDefaults({ ...rest, body: coercedBody } as Record, args.url, { includeResourceScope: true }); const coercedParams = coerceRecord(params); + // Merge params in before URL defaults so explicit identifiers (e.g. FME's + // workspace_id, passed via params) are visible to applyUrlDefaults's + // legacy-vs-URL-scope precedence check, not just top-level named args. + const argsForUrlDefaults = { ...rest, ...coercedParams, body: coercedBody }; + const input = applyUrlDefaults(argsForUrlDefaults as Record, args.url, { includeResourceScope: true }); if (coercedParams) Object.assign(input, coercedParams); // Validate resource_type and operation before asking user to confirm diff --git a/src/tools/harness-delete.ts b/src/tools/harness-delete.ts index 24a0976d1..1af56af83 100644 --- a/src/tools/harness-delete.ts +++ b/src/tools/harness-delete.ts @@ -46,8 +46,12 @@ export function registerDeleteTool(server: McpServer, registry: Registry, client } const { params, confirm: _confirm, ...rest } = args; - const input = applyUrlDefaults(rest as Record, args.url, { includeResourceScope: true }); const coercedParams = coerceRecord(params); + // Merge params in before URL defaults so explicit identifiers (e.g. FME's + // workspace_id, passed via params) are visible to applyUrlDefaults's + // legacy-vs-URL-scope precedence check, not just top-level named args. + const argsForUrlDefaults = coercedParams ? { ...rest, ...coercedParams } : rest; + const input = applyUrlDefaults(argsForUrlDefaults as Record, args.url, { includeResourceScope: true }); if (coercedParams) Object.assign(input, coercedParams); const identFields = def.identifierFields; const primaryField = identFields.length > 1 diff --git a/src/tools/harness-execute.ts b/src/tools/harness-execute.ts index d34a5e4b4..11130498e 100644 --- a/src/tools/harness-execute.ts +++ b/src/tools/harness-execute.ts @@ -172,8 +172,12 @@ export function registerExecuteTool(server: McpServer, registry: Registry, clien async (args, extra) => { try { const { params, wait, wait_timeout_seconds, wait_poll_interval_seconds, confirm: _confirm, queries: batchQueries, ...rest } = args; - const input = applyUrlDefaults(rest as Record, args.url, { includeResourceScope: true }); const coercedParams = coerceRecord(params); + // Merge params in before URL defaults so explicit identifiers (e.g. FME's + // workspace_id, passed via params) are visible to applyUrlDefaults's + // legacy-vs-URL-scope precedence check, not just top-level named args. + const argsForUrlDefaults = coercedParams ? { ...rest, ...coercedParams } : rest; + const input = applyUrlDefaults(argsForUrlDefaults as Record, args.url, { includeResourceScope: true }); if (coercedParams) Object.assign(input, coercedParams); log.debug("Execute input after params merge", { input: JSON.stringify(input), params: JSON.stringify(params) }); const resourceType = asString(input.resource_type); diff --git a/src/tools/harness-get.ts b/src/tools/harness-get.ts index ae1e8db4c..8708c8138 100644 --- a/src/tools/harness-get.ts +++ b/src/tools/harness-get.ts @@ -46,8 +46,12 @@ export function registerGetTool(server: McpServer, registry: Registry, client: H async (args) => { try { const { params, ...rest } = args; - const input = applyUrlDefaults(rest as Record, args.url, { includeResourceScope: true }); const coercedParams = coerceRecord(params); + // Merge params in before URL defaults so explicit identifiers (e.g. FME's + // workspace_id, passed via params) are visible to applyUrlDefaults's + // legacy-vs-URL-scope precedence check, not just top-level named args. + const argsForUrlDefaults = coercedParams ? { ...rest, ...coercedParams } : rest; + const input = applyUrlDefaults(argsForUrlDefaults as Record, args.url, { includeResourceScope: true }); if (coercedParams) Object.assign(input, coercedParams); const resourceType = asString(input.resource_type); if (!resourceType) { diff --git a/src/tools/harness-list.ts b/src/tools/harness-list.ts index c11c3edd7..23f0a31b0 100644 --- a/src/tools/harness-list.ts +++ b/src/tools/harness-list.ts @@ -48,11 +48,15 @@ export function registerListTool(server: McpServer, registry: Registry, client: async (args) => { try { const { params, filters, ...rest } = args; - const input = applyUrlDefaults(rest as Record, args.url, { includeResourceScope: true }); - // Spread caller-supplied params (path identifiers) and filters into the input // Use coerceRecord to handle LLMs that serialize objects as JSON strings const coercedParams = coerceRecord(params); const coercedFilters = coerceRecord(filters); + // Merge params/filters in before URL defaults so explicit identifiers (e.g. FME's + // workspace_id, passed via filters) are visible to applyUrlDefaults's + // legacy-vs-URL-scope precedence check, not just top-level named args. + const argsForUrlDefaults = { ...rest, ...coercedParams, ...coercedFilters }; + const input = applyUrlDefaults(argsForUrlDefaults as Record, args.url, { includeResourceScope: true }); + // Spread caller-supplied params (path identifiers) and filters into the input if (coercedParams) Object.assign(input, coercedParams); if (coercedFilters) Object.assign(input, coercedFilters); const resourceType = asString(input.resource_type); diff --git a/src/tools/harness-update.ts b/src/tools/harness-update.ts index 74e355d94..b16040d63 100644 --- a/src/tools/harness-update.ts +++ b/src/tools/harness-update.ts @@ -52,8 +52,12 @@ export function registerUpdateTool(server: McpServer, registry: Registry, client const { params, body, confirm: _confirm, ...rest } = args; const coercedBody = typeof body === "string" ? (coerceRecord(body) ?? body) : body; - const input = applyUrlDefaults({ ...rest, body: coercedBody } as Record, args.url, { includeResourceScope: true }); const coercedParams = coerceRecord(params); + // Merge params in before URL defaults so explicit identifiers (e.g. FME's + // workspace_id, passed via params) are visible to applyUrlDefaults's + // legacy-vs-URL-scope precedence check, not just top-level named args. + const argsForUrlDefaults = { ...rest, ...coercedParams, body: coercedBody }; + const input = applyUrlDefaults(argsForUrlDefaults as Record, args.url, { includeResourceScope: true }); if (coercedParams) Object.assign(input, coercedParams); const identFields = def.identifierFields; const primaryField = identFields.length > 1 diff --git a/src/utils/url-parser.ts b/src/utils/url-parser.ts index e72785a9e..9b04629ea 100644 --- a/src/utils/url-parser.ts +++ b/src/utils/url-parser.ts @@ -301,7 +301,15 @@ export function applyUrlDefaults( ) { merged.resource_scope = parsed.resource_scope; } + // A legacy workspace_id (FME's Split.io identifier) takes precedence over + // org/project incidentally present in a UI URL — the two are mutually + // exclusive scoping modes for FME resources (see resolveFmeDualMode). + // Use the caller's declared resource_type when present — the URL's own parsed + // type may be absent or non-FME even when the call itself targets an FME resource. + const declaredResourceType = (args.resource_type as string | undefined) ?? parsed.resource_type; + const skipOrgProjectFromUrl = args.workspace_id !== undefined && declaredResourceType?.startsWith("fme_"); for (const field of MERGEABLE_FIELDS) { + if (skipOrgProjectFromUrl && (field === "org_id" || field === "project_id")) continue; if ((merged[field] === undefined || merged[field] === "") && parsed[field] !== undefined) { merged[field] = parsed[field]; } diff --git a/tests/prompts/feature-flag-rollout.test.ts b/tests/prompts/feature-flag-rollout.test.ts new file mode 100644 index 000000000..ba4c4f399 --- /dev/null +++ b/tests/prompts/feature-flag-rollout.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { registerFeatureFlagRolloutPrompt } from "../../src/prompts/feature-flag-rollout.js"; + +async function createTestClient(): Promise { + const server = new McpServer( + { name: "test-server", version: "0.0.1" }, + { capabilities: { prompts: {} } }, + ); + registerFeatureFlagRolloutPrompt(server); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "0.0.1" }); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + return client; +} + +function promptText(result: Awaited>): string { + return (result.messages[0].content as { type: string; text: string }).text; +} + +describe("feature-flag-rollout prompt", () => { + it("appears in the prompt list", async () => { + const client = await createTestClient(); + const { prompts } = await client.listPrompts(); + + const prompt = prompts.find((p) => p.name === "feature-flag-rollout"); + expect(prompt).toBeDefined(); + expect(prompt!.description).toContain("progressive FME feature flag rollout"); + }); + + it("interpolates legacy workspaceId scope args", async () => { + const client = await createTestClient(); + const result = await client.getPrompt({ + name: "feature-flag-rollout", + arguments: { + featureFlagName: "dark_mode", + workspaceId: "ws-legacy", + }, + }); + + const text = promptText(result); + expect(text).toContain('feature flag "dark_mode" (workspace_id="ws-legacy")'); + expect(text).toContain('resource_type="fme_feature_flag", feature_flag_name="dark_mode", workspace_id="ws-legacy"'); + expect(text).not.toContain("org_id="); + expect(text).not.toContain("not yet implemented"); + }); + + it("interpolates Harness-native orgId+projectId scope args and adds the NYI caveat", async () => { + const client = await createTestClient(); + const result = await client.getPrompt({ + name: "feature-flag-rollout", + arguments: { + featureFlagName: "dark_mode", + orgId: "my-org", + projectId: "my-project", + }, + }); + + const text = promptText(result); + expect(text).toContain('org_id="my-org", project_id="my-project"'); + expect(text).toContain('resource_type="fme_environment", org_id="my-org", project_id="my-project"'); + expect(text).toContain("fme_feature_flag_definition"); + expect(text).toContain("fme_rollout_status"); + expect(text).toContain("kill/restore execute action are not yet implemented"); + expect(text).not.toContain("workspace_id="); + }); + + it("rejects calls with no scope identifiers", async () => { + const client = await createTestClient(); + + await expect( + client.getPrompt({ + name: "feature-flag-rollout", + arguments: { featureFlagName: "dark_mode" }, + }), + ).rejects.toThrow("Provide either workspaceId (deprecated) or orgId + projectId."); + }); + + it("rejects a partial orgId without projectId", async () => { + const client = await createTestClient(); + + await expect( + client.getPrompt({ + name: "feature-flag-rollout", + arguments: { featureFlagName: "dark_mode", orgId: "my-org" }, + }), + ).rejects.toThrow("Provide either workspaceId (deprecated) or orgId + projectId."); + }); +}); diff --git a/tests/registry/feature-flags.test.ts b/tests/registry/feature-flags.test.ts index 726ac29e5..5164aa5b0 100644 --- a/tests/registry/feature-flags.test.ts +++ b/tests/registry/feature-flags.test.ts @@ -81,13 +81,12 @@ describe("FME registry metadata", () => { expect(createSpec.description).not.toContain("traffic_type_id (get from fme_workspace)"); }); - it("documents fme_traffic_type list workspace_id requirement", () => { + it("documents fme_traffic_type list workspace_id as a deprecated (non-required) filter", () => { const resource = findResource("fme_traffic_type"); expect(resource.listFilterFields).toContainEqual({ name: "workspace_id", - description: "FME workspace ID (get from fme_workspace)", - required: true, + description: "FME workspace ID (get from fme_workspace). Deprecated — omit and pass org_id+project_id instead for Harness-native scoping.", }); }); @@ -196,6 +195,119 @@ describe("FME execute action response projection", () => { }); }); +describe("fme_feature_flag dual-mode routing", () => { + let registry: Registry; + + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it("legacy mode: workspace_id routes to Split.io unchanged", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_feature_flag", "list", { workspace_id: "ws1" }); + + const req = firstRequest(mockRequest); + expect(req.path).toBe("/internal/api/v2/splits/ws/ws1"); + expect(req.product).toBe("fme"); + expect(req.params?.orgIdentifier).toBeUndefined(); + }); + + it("new mode: org_id+project_id routes to the Harness-native feature-flags path", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_feature_flag", "list", { org_id: "o1", project_id: "p1" }); + + const req = firstRequest(mockRequest); + expect(req.path).toBe("/fme/internal/api/v4/feature-flags"); + expect(req.product).toBeUndefined(); + }); + + it("new mode: get routes to the Harness-native path with the flag name in the URL", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_feature_flag", "get", { + org_id: "o1", + project_id: "p1", + feature_flag_name: "my_flag", + }); + + const req = firstRequest(mockRequest); + expect(req.path).toBe("/fme/internal/api/v4/feature-flags/my_flag"); + }); + + it("new mode: create throws not-yet-implemented", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_feature_flag", "create", { + org_id: "o1", + project_id: "p1", + traffic_type_id: "tt1", + body: { name: "x" }, + }), + ).rejects.toThrow(/not yet implemented/i); + }); + + it("mixed params throws the shared error", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_feature_flag", "list", { workspace_id: "ws1", org_id: "o1" }), + ).rejects.toThrow("fme_feature_flag: pass either workspace_id (deprecated) OR org_id+project_id, not both."); + }); + + it("legacy mode: delete routes to Split.io unchanged", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_feature_flag", "delete", { + workspace_id: "ws1", + feature_flag_name: "my_flag", + }); + + const req = firstRequest(mockRequest); + expect(req.method).toBe("DELETE"); + expect(req.path).toBe("/internal/api/v2/splits/ws/ws1/my_flag"); + expect(req.product).toBe("fme"); + }); + + it("new mode: delete routes to the Harness-native path with the flag name in the URL", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_feature_flag", "delete", { + org_id: "o1", + project_id: "p1", + feature_flag_name: "my_flag", + }); + + const req = firstRequest(mockRequest); + expect(req.method).toBe("DELETE"); + expect(req.path).toBe("/fme/internal/api/v4/feature-flags/my_flag"); + expect(req.product).toBeUndefined(); + }); + + it("legacy mode: logs deprecation exactly once per call", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_feature_flag", "list", { workspace_id: "ws1" }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + "[DEPRECATION] fme_feature_flag: workspace_id-based FME calls are deprecated — pass org_id+project_id instead.", + ); + spy.mockRestore(); + }); +}); + describe("fme_identity create", () => { let registry: Registry; @@ -210,8 +322,6 @@ describe("fme_identity create", () => { await registry.dispatch(client, "fme_identity", "create", { traffic_type_id: "tt-user", environment_id: "env-prod", - org_id: "ignored-org", - project_id: "ignored-project", body: { items: [ { key: "user-1", values: { name: "Ada", company: "Acme" } }, @@ -284,8 +394,6 @@ describe("fme_segment_keys update", () => { await registry.dispatch(client, "fme_segment_keys", "update", { environment_id: "env-prod", segment_name: "beta_users", - org_id: "ignored-org", - project_id: "ignored-project", body: { add: ["user-1", "user-2"], comment: "metadata only", @@ -349,3 +457,607 @@ describe("fme_segment_keys update", () => { expect(getOperation("fme_segment_keys", "update").skipScopeBodyInjection).toBe(true); }); }); + +describe("fme_environment dual-mode routing", () => { + let registry: Registry; + + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it("new mode: list routes to /fme/internal/api/v4/environments", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_environment", "list", { + org_id: "o1", + project_id: "p1", + }); + + expect(firstRequest(mockRequest).path).toBe("/fme/internal/api/v4/environments"); + }); + + it("legacy mode: list routes to /internal/api/v2/environments/ws/{wsId}", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_environment", "list", { + workspace_id: "ws1", + }); + + expect(firstRequest(mockRequest).path).toBe("/internal/api/v2/environments/ws/ws1"); + }); +}); + +describe("fme_standard_segment dual-mode routing", () => { + let registry: Registry; + + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it("new mode: list routes to /fme/internal/api/v4/segments", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_standard_segment", "list", { + org_id: "o1", + project_id: "p1", + }); + + expect(firstRequest(mockRequest).path).toBe("/fme/internal/api/v4/segments"); + }); + + it("legacy mode: list routes to /internal/api/v2/segments/ws/{wsId}", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_standard_segment", "list", { + workspace_id: "ws1", + }); + + expect(firstRequest(mockRequest).path).toBe("/internal/api/v2/segments/ws/ws1"); + }); + + it("new mode: get routes to /fme/internal/api/v4/segments/{segment_name}", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_standard_segment", "get", { + org_id: "o1", + project_id: "p1", + segment_name: "seg1", + }); + + expect(firstRequest(mockRequest).path).toBe("/fme/internal/api/v4/segments/seg1"); + }); + + it("legacy mode: get routes to /internal/api/v2/segments/ws/{wsId}/{segment_name}", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_standard_segment", "get", { + workspace_id: "ws1", + segment_name: "seg1", + }); + + expect(firstRequest(mockRequest).path).toBe("/internal/api/v2/segments/ws/ws1/seg1"); + }); +}); + +describe("fme_rule_based_segment dual-mode routing", () => { + let registry: Registry; + + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it("new mode: list also routes to /fme/internal/api/v4/segments (shared collection)", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_rule_based_segment", "list", { + org_id: "o1", + project_id: "p1", + }); + + expect(firstRequest(mockRequest).path).toBe("/fme/internal/api/v4/segments"); + }); + + it("legacy mode: list routes to /internal/api/v2/rule-based-segments/ws/{wsId}", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_rule_based_segment", "list", { + workspace_id: "ws1", + }); + + expect(firstRequest(mockRequest).path).toBe("/internal/api/v2/rule-based-segments/ws/ws1"); + }); + + it("new mode: get routes to /fme/internal/api/v4/segments/{segment_name}", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_rule_based_segment", "get", { + org_id: "o1", + project_id: "p1", + segment_name: "seg1", + }); + + expect(firstRequest(mockRequest).path).toBe("/fme/internal/api/v4/segments/seg1"); + }); + + it("legacy mode: get routes to /internal/api/v2/rule-based-segments/ws/{wsId}/{segment_name}", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_rule_based_segment", "get", { + workspace_id: "ws1", + segment_name: "seg1", + }); + + expect(firstRequest(mockRequest).path).toBe("/internal/api/v2/rule-based-segments/ws/ws1/seg1"); + }); + + it("new mode: delete routes /fme/internal/api/v4/segments/{segment_name}", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_rule_based_segment", "delete", { + org_id: "o1", + project_id: "p1", + segment_name: "seg1", + }); + + expect(firstRequest(mockRequest).path).toBe("/fme/internal/api/v4/segments/seg1"); + }); + + it("legacy mode: delete routes to /internal/api/v2/rule-based-segments/ws/{wsId}/{segment_name}", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_rule_based_segment", "delete", { + workspace_id: "ws1", + segment_name: "seg1", + }); + + expect(firstRequest(mockRequest).path).toBe("/internal/api/v2/rule-based-segments/ws/ws1/seg1"); + }); + + it("new mode: create throws not-yet-implemented", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_rule_based_segment", "create", { + org_id: "o1", + project_id: "p1", + traffic_type_id: "tt1", + body: { name: "x" }, + }), + ).rejects.toThrow(/not yet implemented/i); + }); +}); + +describe("FME new-mode (NYI) resources", () => { + let registry: Registry; + + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it.each([ + ["fme_feature_flag_definition", "get", { workspace_id: "ws1", feature_flag_name: "f1", environment_id: "e1" }], + [ + "fme_feature_flag_definition", + "create", + { + workspace_id: "ws1", + feature_flag_name: "f1", + environment_id: "e1", + body: { treatments: [{ name: "on" }], defaultTreatment: "on", defaultRule: [{ treatment: "on", size: 100 }] }, + }, + ], + [ + "fme_feature_flag_definition", + "update", + { workspace_id: "ws1", feature_flag_name: "f1", environment_id: "e1", body: { treatments: [{ name: "on" }] } }, + ], + ["fme_rule_based_segment_definition", "list", { workspace_id: "ws1", environment_id: "e1" }], + [ + "fme_rule_based_segment_definition", + "update", + { workspace_id: "ws1", segment_name: "seg1", environment_id: "e1", body: {} }, + ], + ["fme_rollout_status", "list", { workspace_id: "ws1" }], + ["fme_traffic_type", "list", { workspace_id: "ws1" }], + ] as [string, "get" | "create" | "update" | "list", Record][])( + "%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([ + ["enable", { workspace_id: "ws1", environment_id: "e1", segment_name: "seg1" }], + ["disable", { workspace_id: "ws1", environment_id: "e1", segment_name: "seg1" }], + [ + "change_request", + { + workspace_id: "ws1", + environment_id: "e1", + title: "t", + operationType: "UPDATE", + ruleBasedSegment: { title: "seg" }, + }, + ], + ] as [string, Record][])( + "fme_rule_based_segment_definition.%s action: legacy mode still works, new mode throws not-yet-implemented", + async (action, legacyInput) => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatchExecute(client, "fme_rule_based_segment_definition", action, legacyInput); + expect(mockRequest).toHaveBeenCalledTimes(1); + + const newModeInput = { ...legacyInput, workspace_id: undefined, org_id: "o1", project_id: "p1" }; + await expect( + registry.dispatchExecute(client, "fme_rule_based_segment_definition", action, newModeInput), + ).rejects.toThrow(/not yet implemented/i); + }, + ); +}); + +describe("fme_workspace permissive resolver", () => { + let registry: Registry; + + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it("legacy mode: list with zero identifiers still works and logs a deprecation warning", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_workspace", "list", {}); + + expect(firstRequest(mockRequest).path).toBe("/internal/api/v2/workspaces"); + expect(spy).toHaveBeenCalledWith(expect.stringContaining("[DEPRECATION] fme_workspace")); + spy.mockRestore(); + }); + + it("org_id+project_id throws a dedicated no-equivalent error, not the generic NYI message", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_workspace", "list", { org_id: "o1", project_id: "p1" }), + ).rejects.toThrow(/no Harness-native equivalent/i); + }); +}); + +describe("fme_identity permissive mode-selector", () => { + let registry: Registry; + + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it("legacy mode (current traffic_type_id/environment_id contract) still works for create", async () => { + const mockRequest = vi.fn().mockResolvedValue({ ok: true }); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_identity", "create", { + traffic_type_id: "tt-user", + environment_id: "env-prod", + body: { items: [{ key: "user-1", values: { name: "Ada" } }] }, + }); + + expect(mockRequest).toHaveBeenCalledTimes(1); + expect(firstRequest(mockRequest).path).toBe("/internal/api/v2/trafficTypes/tt-user/environments/env-prod/identities"); + }); + + it("org_id+project_id throws not-yet-implemented for create", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_identity", "create", { + traffic_type_id: "tt-user", + environment_id: "env-prod", + org_id: "o1", + project_id: "p1", + body: { items: [{ key: "user-1", values: { name: "Ada" } }] }, + }), + ).rejects.toThrow(/not yet implemented/i); + }); + + it("legacy mode still works for update", async () => { + const mockRequest = vi.fn().mockResolvedValue({ ok: true }); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_identity", "update", { + traffic_type_id: "tt-user", + environment_id: "env-prod", + key: "user-1", + body: { values: { name: "Ada" } }, + }); + + expect(mockRequest).toHaveBeenCalledTimes(1); + expect(firstRequest(mockRequest).path).toBe( + "/internal/api/v2/trafficTypes/tt-user/environments/env-prod/identities/user-1", + ); + }); + + it("org_id+project_id throws not-yet-implemented for update", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_identity", "update", { + traffic_type_id: "tt-user", + environment_id: "env-prod", + key: "user-1", + org_id: "o1", + project_id: "p1", + body: { values: { name: "Ada" } }, + }), + ).rejects.toThrow(/not yet implemented/i); + }); +}); + +describe("fme_segment_keys permissive mode-selector", () => { + let registry: Registry; + + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it("legacy mode (current environment_id/segment_name contract) still works for list", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_segment_keys", "list", { + environment_id: "env-prod", + segment_name: "beta_users", + }); + + expect(mockRequest).toHaveBeenCalledTimes(1); + 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 () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_segment_keys", "list", { + environment_id: "env-prod", + segment_name: "beta_users", + org_id: "o1", + project_id: "p1", + }), + ).rejects.toThrow(/not yet implemented/i); + }); + + it("legacy mode still works for update", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_segment_keys", "update", { + environment_id: "env-prod", + segment_name: "beta_users", + body: { add: ["user-1"] }, + }); + + expect(mockRequest).toHaveBeenCalledTimes(1); + 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 () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_segment_keys", "update", { + environment_id: "env-prod", + segment_name: "beta_users", + org_id: "o1", + project_id: "p1", + body: { add: ["user-1"] }, + }), + ).rejects.toThrow(/not yet implemented/i); + }); +}); + +describe("FME required identifier validation", () => { + let registry: Registry; + + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it("fme_feature_flag get rejects a missing feature_flag_name before any request", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_feature_flag", "get", { workspace_id: "ws1" }), + ).rejects.toThrow('fme_feature_flag: "feature_flag_name" is required.'); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it("fme_feature_flag delete rejects a missing feature_flag_name instead of sending a trailing-slash path", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_feature_flag", "delete", { workspace_id: "ws1" }), + ).rejects.toThrow('fme_feature_flag: "feature_flag_name" is required.'); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it("fme_feature_flag get rejects a missing feature_flag_name in Harness-native mode too", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_feature_flag", "get", { org_id: "o1", project_id: "p1" }), + ).rejects.toThrow('fme_feature_flag: "feature_flag_name" is required.'); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it("fme_rule_based_segment get rejects a missing segment_name", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_rule_based_segment", "get", { workspace_id: "ws1" }), + ).rejects.toThrow('fme_rule_based_segment: "segment_name" is required.'); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it("fme_rule_based_segment delete rejects an empty segment_name", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_rule_based_segment", "delete", { workspace_id: "ws1", segment_name: "" }), + ).rejects.toThrow('fme_rule_based_segment: "segment_name" is required.'); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it("fme_standard_segment get rejects a missing segment_name", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_standard_segment", "get", { workspace_id: "ws1" }), + ).rejects.toThrow('fme_standard_segment: "segment_name" is required.'); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it("fme_feature_flag kill rejects a missing environment_id", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatchExecute(client, "fme_feature_flag", "kill", { workspace_id: "ws1", feature_flag_name: "my_flag" }), + ).rejects.toThrow('fme_feature_flag: "environment_id" is required.'); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it("fme_identity update rejects a missing key", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_identity", "update", { + traffic_type_id: "tt-user", + environment_id: "env-prod", + body: { values: { name: "Ada" } }, + }), + ).rejects.toThrow('fme_identity: "key" is required.'); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it("fme_segment_keys update rejects a missing segment_name", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_segment_keys", "update", { + environment_id: "env-prod", + body: { add: ["user-1"] }, + }), + ).rejects.toThrow('fme_segment_keys: "segment_name" is required.'); + expect(mockRequest).not.toHaveBeenCalled(); + }); +}); + +describe("FME permissive mode-selector partial scope pairs", () => { + let registry: Registry; + + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it("fme_identity create rejects a lone org_id instead of leaking orgIdentifier onto the legacy call", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_identity", "create", { + traffic_type_id: "tt-user", + environment_id: "env-prod", + org_id: "o1", + body: { items: [{ key: "user-1", values: { name: "Ada" } }] }, + }), + ).rejects.toThrow("fme_identity.create: project_id is required when org_id is provided."); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it("fme_identity create rejects a lone project_id", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_identity", "create", { + traffic_type_id: "tt-user", + environment_id: "env-prod", + project_id: "p1", + body: { items: [{ key: "user-1", values: { name: "Ada" } }] }, + }), + ).rejects.toThrow("fme_identity.create: org_id is required when project_id is provided."); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it("fme_segment_keys list rejects a lone org_id", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_segment_keys", "list", { + environment_id: "env-prod", + segment_name: "beta_users", + org_id: "o1", + }), + ).rejects.toThrow("fme_segment_keys.list: project_id is required when org_id is provided."); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it("fme_rule_based_segment_definition enable rejects a lone org_id", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatchExecute(client, "fme_rule_based_segment_definition", "enable", { + environment_id: "env-prod", + segment_name: "beta_users", + org_id: "o1", + }), + ).rejects.toThrow("fme_rule_based_segment_definition.enable: project_id is required when org_id is provided."); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it("fme_workspace list rejects a lone org_id", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_workspace", "list", { org_id: "o1" }), + ).rejects.toThrow("fme_workspace.list: project_id is required when org_id is provided."); + expect(mockRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/registry/scope-utils.test.ts b/tests/registry/scope-utils.test.ts index 887be530a..2aaa81a50 100644 --- a/tests/registry/scope-utils.test.ts +++ b/tests/registry/scope-utils.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { templateV1BasePathFromScope } from "../../src/registry/scope-utils.js"; +import { describe, expect, it, vi } from "vitest"; +import { templateV1BasePathFromScope, resolveFmeDualMode, requireFmeIdentifier, isFmeHarnessNativeSelected } from "../../src/registry/scope-utils.js"; import type { PathBuilderConfig } from "../../src/registry/types.js"; const config: PathBuilderConfig = { @@ -64,3 +64,91 @@ describe("templateV1BasePathFromScope", () => { ).toBe("/v1/orgs/org%2Fwith%20space/projects/proj%26special/templates"); }); }); + +describe("resolveFmeDualMode", () => { + it("throws workspace_id mixed with org_id or project_id", () => { + expect(() => resolveFmeDualMode({ workspace_id: "ws1", org_id: "o1" }, "fme_feature_flag")).toThrow( + "fme_feature_flag: pass either workspace_id (deprecated) OR org_id+project_id, not both.", + ); + expect(() => + resolveFmeDualMode({ workspace_id: "ws1", project_id: "p1" }, "fme_feature_flag"), + ).toThrow("fme_feature_flag: pass either workspace_id (deprecated) OR org_id+project_id, not both."); + }); + + it("returns legacy mode and logs deprecation warning when workspace_id passed", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const result = resolveFmeDualMode({ workspace_id: "ws1" }, "fme_feature_flag"); + expect(result).toEqual({ mode: "legacy", workspaceId: "ws1" }); + expect(spy).toHaveBeenCalledWith( + "[DEPRECATION] fme_feature_flag: workspace_id-based FME calls are deprecated — pass org_id+project_id instead.", + ); + spy.mockRestore(); + }); + + it("returns harness_native mode when org_id and project_id passed without workspace_id", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const result = resolveFmeDualMode({ org_id: "o1", project_id: "p1" }, "fme_feature_flag"); + expect(result).toEqual({ mode: "harness_native", orgId: "o1", projectId: "p1" }); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it("throws when only project_id is passed", () => { + expect(() => resolveFmeDualMode({ project_id: "p1" }, "fme_feature_flag")).toThrow( + "fme_feature_flag: org_id and project_id are required (account is taken from config), or pass the deprecated workspace_id instead.", + ); + }); + + it("throws when only org_id is passed", () => { + expect(() => resolveFmeDualMode({ org_id: "o1" }, "fme_feature_flag")).toThrow( + "fme_feature_flag: org_id and project_id are required (account is taken from config), or pass the deprecated workspace_id instead.", + ); + }); + + it("throws when neither workspace_id nor org_id/project_id are passed", () => { + expect(() => resolveFmeDualMode({}, "fme_feature_flag")).toThrow( + "fme_feature_flag: org_id and project_id are required (account is taken from config), or pass the deprecated workspace_id instead.", + ); + }); +}); + +describe("requireFmeIdentifier", () => { + it("returns the stringified value when present", () => { + expect(requireFmeIdentifier({ feature_flag_name: "my_flag" }, "feature_flag_name", "fme_feature_flag")).toBe("my_flag"); + expect(requireFmeIdentifier({ traffic_type_id: 42 }, "traffic_type_id", "fme_feature_flag")).toBe("42"); + }); + + it.each([undefined, null, ""])("throws for %p", (value) => { + expect(() => requireFmeIdentifier({ feature_flag_name: value }, "feature_flag_name", "fme_feature_flag")).toThrow( + 'fme_feature_flag: "feature_flag_name" is required.', + ); + }); + + it("throws when the field is absent entirely", () => { + expect(() => requireFmeIdentifier({}, "segment_name", "fme_standard_segment")).toThrow( + 'fme_standard_segment: "segment_name" is required.', + ); + }); +}); + +describe("isFmeHarnessNativeSelected", () => { + it("returns true for a complete org_id+project_id pair", () => { + expect(isFmeHarnessNativeSelected({ org_id: "o1", project_id: "p1" }, "fme_identity.create")).toBe(true); + }); + + it("returns false when neither is provided (legacy contract)", () => { + expect(isFmeHarnessNativeSelected({ environment_id: "env" }, "fme_identity.create")).toBe(false); + }); + + it("throws for a lone org_id", () => { + expect(() => isFmeHarnessNativeSelected({ org_id: "o1" }, "fme_identity.create")).toThrow( + "fme_identity.create: project_id is required when org_id is provided.", + ); + }); + + it("throws for a lone project_id", () => { + expect(() => isFmeHarnessNativeSelected({ project_id: "p1" }, "fme_identity.create")).toThrow( + "fme_identity.create: org_id is required when project_id is provided.", + ); + }); +}); diff --git a/tests/tools/tool-handlers.test.ts b/tests/tools/tool-handlers.test.ts index bbf6457dd..0e8948be5 100644 --- a/tests/tools/tool-handlers.test.ts +++ b/tests/tools/tool-handlers.test.ts @@ -172,6 +172,27 @@ describe("harness_list", () => { expect(call.params.projectIdentifier).toBeUndefined(); }); + it("does not merge URL org/project when workspace_id is passed via filters for FME list", async () => { + registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "feature-flags" })); + mockRequest = vi.fn().mockResolvedValue({ objects: [], totalCount: 0, offset: 0, limit: 20 }); + client = makeClient(mockRequest); + const fmeServer = makeMcpServer(); + const { registerListTool } = await import("../../src/tools/harness-list.js"); + registerListTool(fmeServer, registry, client); + + const result = await fmeServer.call("harness_list", { + resource_type: "fme_feature_flag", + url: "https://app.harness.io/ng/account/abc123/cf/orgs/default/projects/myProject/feature-flags", + filters: { workspace_id: "workspace-1" }, + }); + + expect(result.isError).toBeUndefined(); + const call = mockRequest.mock.calls[0]![0] as { path: string; params: Record }; + expect(call.path).toBe("/internal/api/v2/splits/ws/workspace-1"); + expect(call.params.orgIdentifier).toBeUndefined(); + expect(call.params.projectIdentifier).toBeUndefined(); + }); + it("propagates user-fixable API errors as errorResult", async () => { mockRequest.mockRejectedValueOnce(new HarnessApiError("Not found", 404)); const result = await server.call("harness_list", { resource_type: "pipeline" }); diff --git a/tests/utils/url-parser.test.ts b/tests/utils/url-parser.test.ts index 21b2d4f5a..fdca40b4e 100644 --- a/tests/utils/url-parser.test.ts +++ b/tests/utils/url-parser.test.ts @@ -438,6 +438,37 @@ describe("applyUrlDefaults", () => { expect(result.execution_id).toBe("exec123"); }); + it("skips URL org/project for an FME legacy workspace_id call whose URL carries no FME resource type", () => { + const result = applyUrlDefaults( + { resource_type: "fme_feature_flag", workspace_id: "ws1" } as Record, + "https://app.harness.io/ng/account/abc/all/orgs/myOrg/projects/myProject/services", + ); + + expect(result.org_id).toBeUndefined(); + expect(result.project_id).toBeUndefined(); + expect(result.workspace_id).toBe("ws1"); + }); + + it("still merges URL org/project when the caller declares a non-FME resource type", () => { + const result = applyUrlDefaults( + { resource_type: "service", workspace_id: "unrelated-workspace" } as Record, + "https://app.harness.io/ng/account/abc/all/orgs/myOrg/projects/myProject/services", + ); + + expect(result.org_id).toBe("myOrg"); + expect(result.project_id).toBe("myProject"); + }); + + it("falls back to the URL-parsed resource type when the caller declares none", () => { + const result = applyUrlDefaults( + { workspace_id: "ws1" } as Record, + "https://app.harness.io/ng/account/abc/cf/orgs/myOrg/projects/myProject/feature-flags/my_flag", + ); + + expect(result.org_id).toBeUndefined(); + expect(result.project_id).toBeUndefined(); + }); + it("does not mutate the original args object", () => { const args = { resource_type: "pipeline" }; const result = applyUrlDefaults(