diff --git a/README.md b/README.md index 39ac032c6..7a1dc7245 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![MCP Toplist](https://mcptoplist.com/badge/glama%2Fharness%2Fmcp-server.svg)](https://mcptoplist.com/server/glama%2Fharness%2Fmcp-server) -An MCP (Model Context Protocol) server that gives AI agents full access to the Harness.io platform through 11 consolidated tools and 228 resource types. +An MCP (Model Context Protocol) server that gives AI agents full access to the Harness.io platform through 11 consolidated tools and 239 resource types. ## Why Use This MCP Server @@ -10,8 +10,8 @@ Most MCP servers map one tool per API endpoint. For a platform as broad as Harne This server is built differently: -- **11 tools, 228 resource types.** A registry-based dispatch system routes `harness_list`, `harness_get`, `harness_create`, etc. to any Harness resource — pipelines, services, environments, orgs, projects, feature flags, cost data, and more. The LLM picks from 11 tools instead of hundreds. -- **Full platform coverage.** 38 default toolsets spanning CI/CD, GitOps, Feature Flags, Cloud Cost Management, Security Testing, Chaos Engineering, Database DevOps, Internal Developer Portal, Software Supply Chain, Infrastructure as Code Management, Governance, Service Overrides, Knowledge Graph, and more. Opt-in Ansible coverage is available when you need inventory and playbook data. +- **11 tools, 239 resource types.** A registry-based dispatch system routes `harness_list`, `harness_get`, `harness_create`, etc. to any Harness resource — pipelines, services, environments, orgs, projects, feature flags, cost data, and more. The LLM picks from 11 tools instead of hundreds. +- **Full platform coverage.** 39 default toolsets spanning CI/CD, GitOps, Feature Flags, Cloud Cost Management, Security Testing, Chaos Engineering, Database DevOps, Internal Developer Portal, Software Supply Chain, Infrastructure as Code Management, Release Management, Governance, Service Overrides, Knowledge Graph, and more. Opt-in Ansible coverage is available when you need inventory and playbook data. - **Multi-project workflows out of the box.** Agents discover organizations and projects dynamically — no hardcoded env vars needed. Ask "show failed executions across all projects" and the agent can navigate the full account hierarchy. - **34 prompt templates.** Pre-built prompts for common workflows: build & deploy apps end-to-end, debug failed pipelines, review DORA metrics, triage vulnerabilities, optimize cloud costs, audit access control, plan feature flag rollouts, review pull requests, approve pending pipelines, and more. - **Works everywhere.** Stdio transport for local clients (Claude Desktop, Cursor, Devin Desktop), HTTP transport for remote/shared deployments, Docker and Kubernetes ready. @@ -661,19 +661,19 @@ Current multi-scope resources include `connector`, `service`, `environment`, `in **Structured output:** Every tool declares an MCP `outputSchema`. `harness_list` normalizes list-like Harness responses into object-shaped structured content so strict clients can validate it: top-level arrays become `{ "items": [...], "total": , "page": }`, and common wrapper keys such as `content`, `data`, `body`, `objects`, or `features` are hoisted to `items` when needed. The text response still contains the compact JSON payload returned to all clients. -| Tool | Description | -| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `harness_describe` | Discover available resource types, operations, and fields. No API call — returns local registry metadata. | -| `harness_schema` | Fetch exact YAML/JSON Schema definitions and examples for creating/updating resources. Pipeline/template schemas are bundled; connector, environment, service, secret, and infrastructure schemas are scope-aware entity schemas fetched from bundled snapshots or NG `/yaml-schema`. Supports deep drilling via `path`. | -| `harness_list` | List resources of a given type with filtering, search, and pagination. | -| `harness_get` | Get a single resource by its identifier. | -| `harness_create` | Create a new resource. Supports inline and remote (Git-backed) pipelines. Prompts for user confirmation via [elicitation](#elicitation). | -| `harness_update` | Update an existing resource. Supports inline and remote (Git-backed) pipelines. Prompts for user confirmation via [elicitation](#elicitation). | -| `harness_delete` | Delete a resource. Prompts for user confirmation via [elicitation](#elicitation). Destructive. | -| `harness_execute` | Execute an action on a resource (run/retry pipeline, import pipeline from Git, toggle flag, sync app). Prompts for user confirmation via [elicitation](#elicitation). For pipeline runs, use the runtime-input workflow below (supports `branch`/`tag`/`pr_number`/`commit_sha` shorthand expansion). | +| Tool | Description | +| --------------------| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `harness_describe` | Discover available resource types, operations, and fields. No API call — returns local registry metadata. | +| `harness_schema` | Fetch exact YAML/JSON Schema definitions and examples for creating/updating resources. Pipeline/template schemas are bundled; connector, environment, service, secret, and infrastructure schemas are scope-aware entity schemas fetched from bundled snapshots or NG `/yaml-schema`; `release_process` and `release_activity` schemas are fetched live from RMG `/api/yamlSchema`. Supports deep drilling via `path`. | +| `harness_list` | List resources of a given type with filtering, search, and pagination. | +| `harness_get` | Get a single resource by its identifier. | +| `harness_create` | Create a new resource. Supports inline and remote (Git-backed) pipelines. Prompts for user confirmation via [elicitation](#elicitation). | +| `harness_update` | Update an existing resource. Supports inline and remote (Git-backed) pipelines. Prompts for user confirmation via [elicitation](#elicitation). | +| `harness_delete` | Delete a resource. Prompts for user confirmation via [elicitation](#elicitation). Destructive. | +| `harness_execute` | Execute an action on a resource (run/retry pipeline, import pipeline from Git, toggle flag, sync app). Prompts for user confirmation via [elicitation](#elicitation). For pipeline runs, use the runtime-input workflow below (supports `branch`/`tag`/`pr_number`/`commit_sha` shorthand expansion). | | `harness_search` | Search across Harness resource types with a single query. Uses semantic routing (local `all-MiniLM-L6-v2` ONNX embeddings, 384-dim) to predict relevant resource types from a `knowledge` corpus indexed at startup — typically narrowing from ~163 types to 1–8 before scatter-gather. Falls back to full keyword scatter-gather when semantic confidence is low. Response includes `semantic_routed` and `types_skipped` when routing fires. See `docs/search-guidelines.md` for how to make new resource types discoverable. | -| `harness_diagnose` | Diagnose `pipeline`, `connector`, `delegate`, and `gitops_application` resources (aliases: `execution` -> `pipeline`, `gitops_app` -> `gitops_application`). For pipelines, returns stage/step timing and failure details; for connectors/delegates/GitOps apps, returns targeted health and troubleshooting signals. | -| `harness_status` | Get a real-time project health dashboard — recent executions, failure rates, and deep links. | +| `harness_diagnose` | Diagnose `pipeline`, `connector`, `delegate`, and `gitops_application` resources (aliases: `execution` -> `pipeline`, `gitops_app` -> `gitops_application`). For pipelines, returns stage/step timing and failure details; for connectors/delegates/GitOps apps, returns targeted health and troubleshooting signals. | +| `harness_status` | Get a real-time project health dashboard — recent executions, failure rates, and deep links. | ### Schema Lookup Workflow @@ -682,6 +682,7 @@ Use `harness_schema` before creating or updating YAML-backed resources so agents - Bundled schemas include `pipeline`, `template`, `trigger`, `pipeline_v1`, `template_v1`, `inputSet_v1`, `overlayInputSet_v1`, and `agent-pipeline`. - Entity schemas include `connector`, `environment`, `service`, `secret`, and `infrastructure`. They are scope-aware (`account`, `org`, or `project`) and require `org_id`/`project_id` when the selected scope requires them. +- Release Management definitions (`release_process`, `release_activity`) fetch live JSON Schema from RMG `/api/yamlSchema` (not bundled). Pass `scope`, `org_id`, and `project_id` when scoping to org or project. - Vendored entity snapshots are used first when they match the runtime account; otherwise the tool falls back to the Harness NG `/yaml-schema` API and caches the result. - Omit `path` for a field/section summary, then pass a dot-separated `path` to inspect a nested definition. @@ -1202,7 +1203,7 @@ Harness pipelines can be stored in three ways: ## Resource Types -228 resource types organized across 38 toolsets. Each resource type supports a subset of CRUD operations and optional execute actions. +239 resource types organized across 39 toolsets. Each resource type supports a subset of CRUD operations and optional execute actions. ### Platform @@ -1481,6 +1482,36 @@ IaCM list responses expose `page_count` as the count for the current page only ( Use `harness_execute(resource_type="pull_request", action="close", ...)` for an explicit close operation. `harness_update` also accepts `body.state` (`open` or `closed`) and routes state changes to the dedicated Harness Code PR state endpoint; send title/description edits in a separate update call. +### Release Management + +Release Management (RMG) resources are default-enabled. Definition resources (`release_process`, `release_activity`) support list/get/create/update/delete with `body.yaml`; call `harness_schema(resource_type="release_process"|"release_activity")` before create/update. Execution resources monitor running releases — most list operations require `release_id` (UUID from `harness_list resource_type=release`, or the UI URL slug such as `identifier-1.0.0-abc`). Paste an RMG release URL into `harness_list` to auto-fill `release_id`. + +RMG calls use `${HARNESS_BASE_URL}/gateway/rmg` with account scoping via the `Harness-Account` header. Org/project scope uses header-based scoping when `org_id`/`project_id` are provided. `release_execution_phase` is list-only — use each phase item's `identifier` field as `params.phase_identifier` when calling `harness_get` on phase input/output resources (do not call `harness_get` on `release_execution_phase` itself). Release list `status` filtering is applied client-side on the current page only; keep paging with the same filters when results may span pages. + +| Resource Type | List | Get | Create | Update | Delete | Execute Actions | +| ------------------------------------- | ---- | --- | ------ | ------ | ------ | --------------- | +| `release_process` | x | x | x | x | x | | +| `release_activity` | x | x | x | x | x | | +| `release` | x | x | | | | | +| `release_execution_phase` | x | | | | | | +| `release_execution_task` | x | | | | | | +| `release_execution_activity` | x | | | | | | +| `release_input` | | x | | | | | +| `release_execution_phase_input` | | x | | | | | +| `release_execution_phase_output` | | x | | | | | +| `release_execution_activity_input` | | x | | | | | +| `release_execution_activity_output` | | x | | | | | + +Typical workflow: + +1. `harness_list(resource_type="release_process", org_id="...", project_id="...")` to discover orchestration process definitions. +2. `harness_schema(resource_type="release_process")` (or `release_activity`) before create/update; then `harness_create` / `harness_update` with `body.yaml`. +3. `harness_list(resource_type="release", org_id="...", project_id="...")` to find active or recent releases (default 30-day look-back; optional `filters.status`, `filters.search_term`, `filters.days_back`). +4. `harness_get(resource_type="release", release_id="...")` for release details. +5. `harness_list(resource_type="release_execution_phase", filters={ release_id: "..." })` for phase status; same `release_id` for `release_execution_task` and `release_execution_activity`. +6. `harness_get` on `release_input`, `release_execution_phase_input`, `release_execution_phase_output`, `release_execution_activity_output`, or `release_execution_activity_input` using `release_id` plus `params.phase_identifier` / `params.activity_identifier` / `activity_execution_id` as documented on each resource. + + ### Feature Flags @@ -1791,7 +1822,7 @@ Security exemption execute workflow: ## Toolset Filtering -By default, 38 of 39 toolsets are enabled. One toolset is opt-in and excluded from the defaults: +By default, 39 of 40 toolsets are enabled. One toolset is opt-in and excluded from the defaults: - **`ansible`** — Harness Ansible (inventories, playbooks, hosts, activity). Opt-in because it is project-scoped and adds concepts many users do not need. @@ -1871,6 +1902,7 @@ Available toolset names: | `ai-evals` | eval_dataset, eval_dataset_item, evaluation, eval_run, eval_run_item, eval_run_by_eval, eval_metric, eval_metric_set, eval_metric_set_entry, eval_suite, eval_suite_evaluation, eval_suite_run, eval_target, eval_annotation, eval_analytics, eval_git_settings, eval_registry_item, eval_git_registration, online_eval | | `iacm` | iacm_workspace, iacm_variable_set, iacm_resource, iacm_module, iacm_workspace_costs, iacm_activity_resource_change | | `ansible` *(opt-in)* | ansible_inventory, ansible_playbook, ansible_host, ansible_host_activity, ansible_activity | +| `release-management` | release_process, release_activity, release, release_execution_phase, release_execution_task, release_execution_activity, release_input, release_execution_phase_input, release_execution_phase_output, release_execution_activity_input, release_execution_activity_output | ## Architecture @@ -1888,8 +1920,8 @@ Available toolset names: | +--------v---------+ | Registry | <-- Declarative resource definitions - | 38 Toolsets | (data files, not code) - | 228 Resource Types| + | 39 Toolsets | (data files, not code) + | 239 Resource Types| +--------+---------+ | +--------v---------+ diff --git a/scripts/generate-docs.js b/scripts/generate-docs.js index c267a3a3f..90e9f4a9f 100644 --- a/scripts/generate-docs.js +++ b/scripts/generate-docs.js @@ -20,6 +20,7 @@ const README_COVERAGE_TOOLSETS = new Map([ ["feature-flags", { sectionTitle: "Feature Flags" }], ["file_store", { sectionTitle: "File Store" }], ["chaos", { sectionTitle: "Chaos Engineering" }], + ["release-management", { sectionTitle: "Release Management" }], ]); const CRUD_OPERATIONS = ["list", "get", "create", "update", "delete"]; const CRUD_COLUMN_INDEX = { list: 1, get: 2, create: 3, update: 4, delete: 5 }; diff --git a/src/config.ts b/src/config.ts index bf5097f3f..cb3545b40 100644 --- a/src/config.ts +++ b/src/config.ts @@ -272,6 +272,24 @@ export function resolveProductBaseUrl(config: Config, product: "harness" | "fme" return undefined; } +/** Resolve Release Management (RMG) API root: `${HARNESS_BASE_URL}/gateway/rmg`. */ +export function resolveRmgBaseUrl(config: Config): string { + return `${config.HARNESS_BASE_URL.replace(/\/$/, "")}/gateway/rmg`; +} + +/** + * Resolve per-resource base URL overrides (FME, RMG). Returns undefined to use + * the default Harness client base URL. + */ +export function resolveResourceBaseUrl( + config: Config, + def: { product?: "harness" | "fme"; baseUrlOverride?: "fme" | "rmg" }, +): string | undefined { + if (def.baseUrlOverride === "rmg") return resolveRmgBaseUrl(config); + if (def.baseUrlOverride === "fme" || def.product === "fme") return config.HARNESS_FME_BASE_URL; + return resolveProductBaseUrl(config, def.product ?? "harness"); +} + export function loadConfig(): Config { const result = ConfigSchema.safeParse(process.env); if (!result.success) { diff --git a/src/registry/extractors.ts b/src/registry/extractors.ts index 582ff7048..6d4b85e50 100644 --- a/src/registry/extractors.ts +++ b/src/registry/extractors.ts @@ -4,6 +4,7 @@ */ import { isRecord } from "../utils/type-guards.js"; import { parseZipCsv } from "../utils/zip-csv.js"; +import type { PreflightContext } from "./types.js"; /** Extract `data` from standard NG API responses: `{ status, data, ... }` */ export const ngExtract = (raw: unknown): unknown => { @@ -45,6 +46,18 @@ export const pageExtract = (raw: unknown): { items: unknown[]; total: number } = }; }; +/** + * Spring Data page at the response root (no NG `{ data }` envelope): + * `{ content, totalElements }`. Used by Release Management (RMG) list APIs. + */ +export const springPageExtract = (raw: unknown): { items: unknown[]; total: number } => { + const r = raw as { content?: unknown[]; totalElements?: number }; + return { + items: Array.isArray(r.content) ? r.content : [], + total: typeof r.totalElements === "number" ? r.totalElements : 0, + }; +}; + /** Extract `data` from NG API and wrap primitive values in an object for structuredContent compatibility. */ export const countExtract = (raw: unknown): { count: number; _error?: string } => { const r = raw as { data?: unknown }; @@ -1138,3 +1151,356 @@ export const fmeGetExtract = (raw: unknown): unknown => { } return raw; }; + +// ── Release Management (RMG) ────────────────────────────────────────────── + +const RMG_API_PREFIX = "/api"; +export const RMG_DEFAULT_DAYS_BACK = 30; +export const RMG_MAX_DAYS_BACK = 365; +/** Days of look-ahead included in the window so scheduled releases stay visible. */ +export const RMG_DAYS_FORWARD = 7; +const RMG_DAY_MS = 86_400_000; +export const RMG_DEFAULT_TASK_LIMIT = 50; +export const RMG_MAX_TASK_LIMIT = 100; + +function rmgClampInt(value: unknown, min: number, max: number, fallback: number): number { + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, Math.trunc(n))); +} + +function rmgAsRecord(value: unknown): Record { + return value !== null && typeof value === "object" ? (value as Record) : {}; +} + +/** First key holding an array — RMG list payloads vary by gateway version. */ +function rmgFirstArrayAt( + source: Record, + keys: readonly string[], +): Array> | undefined { + for (const key of keys) { + const value = source[key]; + if (Array.isArray(value)) return value as Array>; + } + return undefined; +} + +/** + * Copy HARNESS_ORG / HARNESS_PROJECT into input when scopeOptional resources + * omit explicit org_id/project_id (same pattern as release list preflight). + */ +export async function releaseFillScopeFromConfig({ input, registry }: PreflightContext): Promise { + if (!input.org_id && registry.orgId) input.org_id = registry.orgId; + if (!input.project_id && registry.projectId) input.project_id = registry.projectId; +} + +/** + * Resolve the effective scope and time window before the body/query are built. + * The registry only merges HARNESS_ORG/HARNESS_PROJECT into `input` when the + * caller passes `resource_scope`, but the body always needs them. + */ +export async function releaseListPreflight(ctx: PreflightContext): Promise { + await releaseFillScopeFromConfig(ctx); + const { input } = ctx; + const now = Date.now(); + const daysBack = rmgClampInt(input.days_back, 1, RMG_MAX_DAYS_BACK, RMG_DEFAULT_DAYS_BACK); + if (input.start_ts === undefined || input.start_ts === "") { + input.start_ts = now - daysBack * RMG_DAY_MS; + } + if (input.end_ts === undefined || input.end_ts === "") { + input.end_ts = now + RMG_DAYS_FORWARD * RMG_DAY_MS; + } +} + +/** POST /api/release/list — scope travels in the body as `scopes`, not as query params. */ +export function releaseListBody(input: Record): Record { + const scope: Record = {}; + if (typeof input.org_id === "string" && input.org_id) scope.orgIdentifier = input.org_id; + if (typeof input.project_id === "string" && input.project_id) { + scope.projectIdentifier = input.project_id; + } + return { scopes: Object.keys(scope).length > 0 ? [scope] : [] }; +} + +/** + * Normalize the list payload and apply the optional status filter client-side + * (the endpoint takes no status param). Status matching is case-insensitive + * because RMG returns both `Running` and `RUNNING` depending on the field. + */ +export function releaseListExtract(raw: unknown, input?: Record): unknown { + const root = rmgAsRecord(raw); + const payload = Array.isArray(raw) + ? { content: raw } + : rmgFirstArrayAt(root, ["content", "releases", "items"]) + ? root + : rmgAsRecord(root.data); + + let items = rmgFirstArrayAt(payload, ["content", "releases", "items"]) ?? []; + const pageItemCount = items.length; + + const requestedStatus = typeof input?.status === "string" ? input.status.trim() : ""; + if (requestedStatus) { + const wanted = requestedStatus.toLowerCase(); + items = items.filter((rel) => { + const status = rel.status ?? rel.releaseStatus; + return typeof status === "string" && status.toLowerCase() === wanted; + }); + } + + const total = ["totalElements", "totalItems", "totalCount", "total"] + .map((key) => payload[key]) + .find((value): value is number => typeof value === "number"); + + return { + items, + total: requestedStatus ? items.length : (total ?? items.length), + ...(requestedStatus + ? { + status_filter: requestedStatus, + _hint: + "status is filtered client-side on this page only; total is the filtered count for this page, " + + `not the account (${pageItemCount} unfiltered items on this page). ` + + "Increment page (keep size and other filters) if matches may exist on later pages.", + } + : {}), + }; +} + +/** Flatten GET /api/release/{id} → { release: releaseInfo, ... }. */ +export function releaseGetExtract(raw: unknown): unknown { + const r = raw as { releaseInfo?: Record }; + if (r.releaseInfo && typeof r.releaseInfo === "object") { + return { release: r.releaseInfo }; + } + return raw; +} + +function rmgSplitCsvFilter(value: unknown): string[] | undefined { + if (typeof value !== "string" || !value.trim()) return undefined; + const parts = value.split(",").map((s) => s.trim()).filter(Boolean); + return parts.length > 0 ? parts : undefined; +} + +/** Normalize sort/status/type filters before query param mapping (GET list). */ +export function normalizeReleaseActivityExecutionInput(input: Record): void { + const sortField = + typeof input.sort_field === "string" && input.sort_field.length > 0 + ? input.sort_field + : "start_ts"; + const sortDirection = input.sort_direction === "asc" ? "asc" : "desc"; + input.sort = [sortField, sortDirection]; + + const statusParts = rmgSplitCsvFilter(input.status); + if (statusParts) input.status = statusParts; + + const typeParts = rmgSplitCsvFilter(input.activity_type); + if (typeParts) input.activity_type = typeParts; +} + +export function normalizeReleaseTaskLimit(input: Record): void { + if (input.limit === undefined && input.size !== undefined) { + input.limit = input.size; + } + input.limit = rmgClampInt(input.limit, 1, RMG_MAX_TASK_LIMIT, RMG_DEFAULT_TASK_LIMIT); +} + +/** GET /api/release/{id}/tasks → standard list envelope with cursor pagination. */ +export function releaseTaskListExtract(raw: unknown, input?: Record): unknown { + const r = raw as { + tasks?: unknown[]; + nextRequest?: { cursor?: string }; + last?: boolean; + }; + const items = Array.isArray(r.tasks) ? r.tasks : []; + const limit = rmgClampInt(input?.limit ?? input?.size, 1, RMG_MAX_TASK_LIMIT, RMG_DEFAULT_TASK_LIMIT); + return { + items, + total: items.length, + limit_applied: limit, + status_filter: typeof input?.status === "string" && input.status ? input.status : "all", + pagination: { + cursor: typeof input?.cursor === "string" ? input.cursor : undefined, + next_cursor: r.nextRequest?.cursor, + last: r.last, + }, + }; +} + +/** GET /api/release/{id}/execution/activities — Spring page at root. */ +export function releaseActivityExecutionListExtract(raw: unknown): unknown { + const r = raw as { + content?: unknown[]; + totalElements?: number; + totalPages?: number; + size?: number; + number?: number; + numberOfElements?: number; + first?: boolean; + last?: boolean; + }; + const items = Array.isArray(r.content) ? r.content : []; + return { + items, + total: typeof r.totalElements === "number" ? r.totalElements : items.length, + pagination: { + page: r.number, + size: r.size, + total_pages: r.totalPages, + total_elements: r.totalElements, + number_of_elements: r.numberOfElements, + first: r.first, + last: r.last, + }, + }; +} + +function requireReleaseAndPhase(input: Record): { releaseId: string; phaseId: string } { + const releaseId = input.release_id; + const phaseId = input.phase_identifier; + if (typeof releaseId !== "string" || !releaseId) { + throw new Error("release_id is required"); + } + if (typeof phaseId !== "string" || !phaseId) { + throw new Error( + "phase_identifier is required — pass via params on harness_get (from harness_list release_execution_phase)", + ); + } + return { releaseId, phaseId }; +} + +export function releaseExecutionPhaseOutputPath(input: Record): string { + const { releaseId, phaseId } = requireReleaseAndPhase(input); + const enc = encodeURIComponent; + return `${RMG_API_PREFIX}/orchestration/execution/release/${enc(releaseId)}/phase/${enc(phaseId)}/output`; +} + +export function releaseExecutionPhaseInputPath(input: Record): string { + const { releaseId, phaseId } = requireReleaseAndPhase(input); + const enc = encodeURIComponent; + return `${RMG_API_PREFIX}/orchestration/execution/release/${enc(releaseId)}/phase/${enc(phaseId)}/input`; +} + +export function releaseExecutionActivityOutputPath(input: Record): string { + const { releaseId, phaseId } = requireReleaseAndPhase(input); + const activityId = input.activity_identifier; + if (typeof activityId !== "string" || !activityId) { + throw new Error( + "activity_identifier is required — pass via params on harness_get (from harness_list release_execution_activity)", + ); + } + const enc = encodeURIComponent; + return `${RMG_API_PREFIX}/orchestration/execution/release/${enc(releaseId)}/phase/${enc(phaseId)}/activity/${enc(activityId)}/output`; +} + +function releasePhaseIoGetExtract( + raw: unknown, + input: Record | undefined, + field: "inputs" | "outputs", +): unknown { + const r = raw as { inputs?: unknown[]; outputs?: unknown[] }; + const items = Array.isArray(r[field]) ? r[field] : []; + return { + [field]: items, + phase_identifier: input?.phase_identifier, + [`total_${field}`]: items.length, + }; +} + +export function releasePhaseOutputGetExtract(raw: unknown, input?: Record): unknown { + return releasePhaseIoGetExtract(raw, input, "outputs"); +} + +export function releasePhaseInputGetExtract(raw: unknown, input?: Record): unknown { + return releasePhaseIoGetExtract(raw, input, "inputs"); +} + +export function releaseActivityOutputGetExtract( + raw: unknown, + input: Record | undefined, +): unknown { + const r = raw as { outputs?: unknown[] }; + const items = Array.isArray(r.outputs) ? r.outputs : []; + return { + outputs: items, + phase_identifier: input?.phase_identifier, + activity_identifier: input?.activity_identifier, + total_outputs: items.length, + }; +} + +export function releaseActivityInputGetExtract(raw: unknown): unknown { + const r = raw as { inputs?: unknown[] }; + const items = Array.isArray(r.inputs) ? r.inputs : []; + return { + inputs: items, + total_inputs: items.length, + }; +} + +export function releaseInputGetExtract(raw: unknown): unknown { + const r = raw as { release_id?: string; process_execution_id?: string; yaml?: string }; + return { + release_id: r.release_id, + process_execution_id: r.process_execution_id, + yaml: r.yaml, + }; +} + +/** GET /api/orchestration/execution/{id}/phases → standard list envelope. */ +export function releasePhaseListExtract(raw: unknown): { + items: unknown[]; + total: number; + release_id?: string; + total_running_phases?: number; +} { + const r = raw as { + phases?: unknown[]; + release_id?: string; + total_running_phases?: number; + }; + const items = Array.isArray(r.phases) ? r.phases : []; + return { + items, + total: items.length, + ...(r.release_id ? { release_id: r.release_id } : {}), + ...(typeof r.total_running_phases === "number" ? { total_running_phases: r.total_running_phases } : {}), + }; +} + +/** Project RMG process/activity YAML entity responses to a stable agent shape. */ +export function rmgYamlEntityExtract(raw: unknown): unknown { + if (!isRecord(raw)) return raw; + const gitDetails = raw.git_details ?? raw.gitDetails; + const out: Record = {}; + if (typeof raw.identifier === "string") out.identifier = raw.identifier; + if (typeof raw.name === "string") out.name = raw.name; + if (typeof raw.yaml === "string") out.yaml = raw.yaml; + if (typeof raw.orgIdentifier === "string") out.orgIdentifier = raw.orgIdentifier; + if (typeof raw.projectIdentifier === "string") out.projectIdentifier = raw.projectIdentifier; + if (gitDetails !== undefined) out.git_details = gitDetails; + if (Object.keys(out).length > 0) return out; + return raw; +} + +/** Normalize RMG delete responses to a stable confirmation shape. */ +export function rmgYamlEntityDeleteExtract(raw: unknown): unknown { + if (raw === null || raw === undefined) return { deleted: true }; + if (!isRecord(raw)) return { deleted: true }; + const out: Record = { deleted: true }; + if (typeof raw.identifier === "string") out.identifier = raw.identifier; + if (raw.status !== undefined) out.status = raw.status; + return out; +} + +export function yamlWriteBody(input: Record): Record { + const b = input.body as Record | undefined; + if (!b || typeof b !== "object") { + throw new Error("body is required and must be an object with yaml (and optional git_details)"); + } + if (typeof b.yaml !== "string" || b.yaml.length === 0) { + throw new Error("body.yaml is required (non-empty YAML string)"); + } + const out: Record = { yaml: b.yaml }; + if (b.git_details !== undefined) out.git_details = b.git_details; + return out; +} diff --git a/src/registry/index.ts b/src/registry/index.ts index cfe3c53b6..f25ebc2cc 100644 --- a/src/registry/index.ts +++ b/src/registry/index.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { type Config, resolveProductBaseUrl } from "../config.js"; +import { type Config, resolveProductBaseUrl, resolveResourceBaseUrl } from "../config.js"; import type { HarnessClient } from "../client/harness-client.js"; import { HarnessApiError } from "../utils/errors.js"; import type { ResourceDefinition, ToolsetDefinition, ToolsetName, OperationName, EndpointSpec, FilterFieldSpec, ResourceScope } from "./types.js"; @@ -52,6 +52,7 @@ import { semanticLayerToolset } from "./toolsets/semantic-layer.js"; import { ansibleToolset } from "./toolsets/ansible.js"; import { incidentsToolset } from "./toolsets/incidents.js"; import { deploysToolset } from "./toolsets/deploys.js"; +import { releaseManagementToolset } from "./toolsets/release-management.js"; const log = createLogger("registry"); @@ -167,6 +168,7 @@ const ALL_TOOLSETS: ToolsetDefinition[] = [ ansibleToolset, incidentsToolset, deploysToolset, + releaseManagementToolset, ]; /** All available toolset names — used by docs generation to discover opt-in toolsets. */ @@ -818,7 +820,9 @@ export class Registry { // Make request — resolve base URL and auth from product backend const product = resolvedRoute?.product ?? def.product ?? "harness"; - const baseUrl = resolveProductBaseUrl(this.config, product); + const baseUrl = def.baseUrlOverride + ? resolveResourceBaseUrl(this.config, def) + : resolveProductBaseUrl(this.config, product); const productHeaders: Record = { ...spec.headers, ...resolvedRoute?.headers }; const requestOpts = { diff --git a/src/registry/toolsets/release-management.ts b/src/registry/toolsets/release-management.ts new file mode 100644 index 000000000..b31d0e2ff --- /dev/null +++ b/src/registry/toolsets/release-management.ts @@ -0,0 +1,856 @@ +/** + * Release Management (RMG) — orchestration definitions and release execution monitoring. + * Paths are service-native `/api/...`. Base URL is `${HARNESS_BASE_URL}/gateway/rmg`. + * Account via Harness-Account header (headerBasedScoping). + */ +import type { BodySchema, ParamsSchema, ToolsetDefinition } from "../types.js"; +import { + normalizeReleaseActivityExecutionInput, + normalizeReleaseTaskLimit, + releaseActivityExecutionListExtract, + releaseActivityInputGetExtract, + releaseActivityOutputGetExtract, + releaseExecutionActivityOutputPath, + releaseExecutionPhaseInputPath, + releaseExecutionPhaseOutputPath, + releaseFillScopeFromConfig, + releaseGetExtract, + releaseInputGetExtract, + releaseListBody, + releaseListExtract, + releaseListPreflight, + releasePhaseInputGetExtract, + releasePhaseListExtract, + releasePhaseOutputGetExtract, + releaseTaskListExtract, + rmgYamlEntityDeleteExtract, + rmgYamlEntityExtract, + RMG_DEFAULT_DAYS_BACK, + RMG_DAYS_FORWARD, + RMG_DEFAULT_TASK_LIMIT, + RMG_MAX_DAYS_BACK, + RMG_MAX_TASK_LIMIT, + springPageExtract, + yamlWriteBody, +} from "../extractors.js"; + +const RMG = "/api"; + +const RELEASE_STATUSES = ["Running", "Success", "Failed", "Scheduled", "Paused", "Aborted"] as const; + +const TASK_STATUSES = ["TODO", "IN_PROGRESS", "SUCCEEDED", "FAILED", "BLOCKED"] as const; + +const ACTIVITY_EXECUTION_STATUSES = [ + "RUNNING", + "SUCCEEDED", + "FAILED", + "ABORTED", + "QUEUED", + "SCHEDULED", + "ON_HOLD", + "OUTPUT_WAITING", + "RETRIED", + "IGNORED", + "SKIPPED", +] as const; + +const ACTIVITY_EXECUTION_TYPES = ["PIPELINE", "SUBPROCESS", "MANUAL"] as const; + +const ACTIVITY_SORT_FIELDS = [ + "start_ts", + "end_ts", + "created_at", + "last_updated_at", + "name", + "identifier", + "status", + "type", +] as const; + +const releaseExecutionPhaseOutputParamsSchema: ParamsSchema = { + fields: [ + { + name: "phase_identifier", + required: true, + description: + "Phase identifier from harness_list resource_type=release_execution_phase (each phase has an `identifier` field)", + }, + { + name: "phase_execution_id", + required: false, + description: "Optional phase execution UUID when multiple phase execution records exist", + }, + ], +}; + +const releaseExecutionActivityOutputParamsSchema: ParamsSchema = { + fields: [ + { + name: "phase_identifier", + required: true, + description: "Phase identifier containing the activity", + }, + { + name: "activity_identifier", + required: true, + description: + "Activity identifier from harness_list resource_type=release_execution_activity (each item has an `identifier` field)", + }, + { + name: "activity_execution_id", + required: false, + description: "Optional activity execution UUID to pin a specific runtime activity row", + }, + ], +}; + +const yamlBodySchema: BodySchema = { + description: + "YAML definition for create/update. Pass body.yaml (required). Optional body.git_details for remote/git-backed entities. " + + "Use harness_schema(resource_type='release_process'|'release_activity') for the full JSON Schema.", + fields: [ + { + name: "yaml", + type: "yaml", + required: true, + description: "Full orchestration YAML string for the process or activity", + }, + { + name: "git_details", + type: "object", + required: false, + description: + "Git metadata when store is remote: store_type, repo, path, connector_ref, branch, commit_msg, is_new_branch, base_branch", + }, + ], +}; + +const gitBranchGetParams: ParamsSchema = { + fields: [ + { + name: "git_branch", + required: false, + description: + "Optional git branch for remote/git-backed YAML. Pass via params on harness_get.", + }, + ], +}; + +export const releaseManagementToolset: ToolsetDefinition = { + name: "release-management", + displayName: "Release Management", + description: + "Harness Release Management (RMG) — orchestration definitions (process/activity YAML) and release execution " + + "monitoring (search releases, get release details, list phases/tasks/activity executions, get outputs). " + + "Definitions: list/get/create/update/delete. " + + "Execution: harness_list resource_type=release (org/project scoped), harness_get release by id/slug, " + + "harness_list release_execution_phase/release_execution_task/release_execution_activity (release_id required), " + + "harness_get release_input|release_execution_phase_input|release_execution_phase_output|release_execution_activity_output|release_execution_activity_input. " + + "YAML JSON Schema: harness_schema(resource_type='release_process'|'release_activity') or GET {rmgBase}/api/yamlSchema?entityType=PROCESS|ACTIVITY.", + optIn: false, + resources: [ + { + resourceType: "release_process", + displayName: "Release Process", + description: + "Orchestration process definition (RMG). A process is a multi-phase release plan composed of activities. " + + "Use harness_list to discover processes, harness_get for YAML, harness_create/update with body.yaml. " + + "Search aliases: orchestration process, RMG process, release plan.", + toolset: "release-management", + scope: "project", + supportedScopes: ["account", "org", "project"], + scopeOptional: true, + headerBasedScoping: true, + baseUrlOverride: "rmg", + identifierFields: ["process_id"], + searchAliases: ["orchestration process", "rmg process", "release plan", "process yaml"], + listFilterFields: [ + { name: "search_term", description: "Filter processes by name or identifier substring" }, + { name: "sort", description: "Sort expression (API sort query param)" }, + ], + relatedResources: [ + { + resourceType: "release_activity", + relationship: "composed_of", + description: "Processes reference activities as phase steps", + }, + ], + operations: { + list: { + method: "GET", + path: `${RMG}/orchestration/process/summary`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + preflight: releaseFillScopeFromConfig, + queryParams: { + search_term: "searchTerm", + sort: "sort", + page: "page", + size: "size", + }, + responseExtractor: springPageExtract, + description: "List orchestration processes (paginated summary metadata)", + }, + get: { + method: "GET", + path: `${RMG}/orchestration/process/{identifier}`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + preflight: releaseFillScopeFromConfig, + pathParams: { process_id: "identifier" }, + queryParams: { git_branch: "git_branch" }, + paramsSchema: gitBranchGetParams, + responseExtractor: rmgYamlEntityExtract, + description: "Get orchestration process YAML and identifier by process_id", + }, + create: { + method: "POST", + path: `${RMG}/orchestration/process`, + operationPolicy: { risk: "medium_write", retryPolicy: "do_not_retry" }, + preflight: releaseFillScopeFromConfig, + bodyBuilder: yamlWriteBody, + bodySchema: yamlBodySchema, + responseExtractor: rmgYamlEntityExtract, + description: "Create an orchestration process from YAML", + }, + update: { + method: "PUT", + path: `${RMG}/orchestration/process/{identifier}`, + operationPolicy: { risk: "medium_write", retryPolicy: "do_not_retry" }, + preflight: releaseFillScopeFromConfig, + pathParams: { process_id: "identifier" }, + bodyBuilder: yamlWriteBody, + bodySchema: yamlBodySchema, + responseExtractor: rmgYamlEntityExtract, + description: "Update an orchestration process YAML (full replacement)", + }, + delete: { + method: "DELETE", + path: `${RMG}/orchestration/process/{identifier}`, + operationPolicy: { risk: "destructive", retryPolicy: "do_not_retry" }, + preflight: releaseFillScopeFromConfig, + pathParams: { process_id: "identifier" }, + responseExtractor: rmgYamlEntityDeleteExtract, + description: "Delete an orchestration process by process_id", + }, + }, + }, + { + resourceType: "release_activity", + displayName: "Release Activity", + description: + "Orchestration activity definition (RMG) — a reusable step (pipeline, subprocess, or manual) " + + "referenced by release processes. Use harness_list/get/create/update/delete with body.yaml. " + + "Use harness_schema(resource_type='release_activity') for the full JSON Schema. " + + "Search aliases: orchestration activity, RMG activity, release step.", + toolset: "release-management", + scope: "project", + supportedScopes: ["account", "org", "project"], + scopeOptional: true, + headerBasedScoping: true, + baseUrlOverride: "rmg", + identifierFields: ["activity_id"], + searchAliases: ["orchestration activity", "rmg activity", "release step", "activity yaml"], + listFilterFields: [ + { name: "search_term", description: "Filter activities by name or identifier substring" }, + { name: "sort", description: "Sort expression (API sort query param)" }, + ], + relatedResources: [ + { + resourceType: "release_process", + relationship: "used_by", + description: "Activities are composed into release processes", + }, + ], + operations: { + list: { + method: "GET", + path: `${RMG}/orchestration/activity/summary`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + preflight: releaseFillScopeFromConfig, + queryParams: { + search_term: "searchTerm", + sort: "sort", + page: "page", + size: "size", + }, + responseExtractor: springPageExtract, + description: "List orchestration activities (paginated summary metadata)", + }, + get: { + method: "GET", + path: `${RMG}/orchestration/activity/{identifier}`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + preflight: releaseFillScopeFromConfig, + pathParams: { activity_id: "identifier" }, + queryParams: { git_branch: "git_branch" }, + paramsSchema: gitBranchGetParams, + responseExtractor: rmgYamlEntityExtract, + description: "Get orchestration activity YAML and identifier by activity_id", + }, + create: { + method: "POST", + path: `${RMG}/orchestration/activity`, + operationPolicy: { risk: "medium_write", retryPolicy: "do_not_retry" }, + preflight: releaseFillScopeFromConfig, + bodyBuilder: yamlWriteBody, + bodySchema: yamlBodySchema, + responseExtractor: rmgYamlEntityExtract, + description: "Create an orchestration activity from YAML", + }, + update: { + method: "PUT", + path: `${RMG}/orchestration/activity/{identifier}`, + operationPolicy: { risk: "medium_write", retryPolicy: "do_not_retry" }, + preflight: releaseFillScopeFromConfig, + pathParams: { activity_id: "identifier" }, + bodyBuilder: yamlWriteBody, + bodySchema: yamlBodySchema, + responseExtractor: rmgYamlEntityExtract, + description: "Update an orchestration activity YAML (full replacement)", + }, + delete: { + method: "DELETE", + path: `${RMG}/orchestration/activity/{identifier}`, + operationPolicy: { risk: "destructive", retryPolicy: "do_not_retry" }, + preflight: releaseFillScopeFromConfig, + pathParams: { activity_id: "identifier" }, + responseExtractor: rmgYamlEntityDeleteExtract, + description: "Delete an orchestration activity by activity_id", + }, + }, + }, + { + resourceType: "release", + displayName: "Release Execution", + description: + "A running or completed release instance (orchestration execution). harness_list returns releases in the " + + `current org/project whose expected start falls in the last ${RMG_DEFAULT_DAYS_BACK} days (override with days_back, ` + + "or start_ts/end_ts); narrow further with search_term and status. " + + "The list item `id` field or UI URL slug (e.g. identifier-1.0.0-abc) can be used as release_id. " + + "Search aliases: active release, release execution, RMG release.", + toolset: "release-management", + scope: "project", + supportedScopes: ["account", "org", "project"], + scopeOptional: true, + headerBasedScoping: true, + baseUrlOverride: "rmg", + identifierFields: ["release_id"], + searchAliases: ["active release", "release execution", "rmg release", "running release"], + listFilterFields: [ + { + name: "search_term", + description: "Search by release name or identifier (substring match)", + }, + { + name: "status", + description: + "Filter by release status (applied client-side on the current page only; the endpoint has no status param). " + + "Empty results can still exist on other pages — keep paging with the same size.", + enum: [...RELEASE_STATUSES], + }, + { + name: "days_back", + type: "number", + description: `Days of history to search (default ${RMG_DEFAULT_DAYS_BACK}, max ${RMG_MAX_DAYS_BACK}; includes ${RMG_DAYS_FORWARD} days forward). Ignored when start_ts/end_ts are set.`, + }, + { + name: "start_ts", + type: "number", + description: "Window start as expected start timestamp (epoch ms) — overrides days_back", + }, + { + name: "end_ts", + type: "number", + description: "Window end as expected start timestamp (epoch ms) — overrides days_back", + }, + ], + relatedResources: [ + { + resourceType: "release_execution_phase", + relationship: "has_phases", + description: + "List execution phases via harness_list(resource_type=release_execution_phase, filters={ release_id })", + }, + { + resourceType: "release_execution_task", + relationship: "has_tasks", + description: "List manual execution tasks via harness_list(resource_type=release_execution_task, filters={ release_id })", + }, + { + resourceType: "release_execution_activity", + relationship: "has_activity_executions", + description: + "List activity executions via harness_list(resource_type=release_execution_activity, filters={ release_id })", + }, + { + resourceType: "release_execution_phase_input", + relationship: "has_phase_inputs", + description: "Get phase inputs via harness_get resource_type=release_execution_phase_input", + }, + { + resourceType: "release_execution_phase_output", + relationship: "has_phase_outputs", + description: "Get phase outputs via harness_get resource_type=release_execution_phase_output", + }, + { + resourceType: "release_execution_activity_output", + relationship: "has_activity_outputs", + description: "Get activity outputs via harness_get resource_type=release_execution_activity_output", + }, + { + resourceType: "release_input", + relationship: "has_release_input", + description: "Get release input YAML via harness_get resource_type=release_input", + }, + { + resourceType: "release_process", + relationship: "defined_by", + description: "Release processes define the orchestration plan executed as releases", + }, + ], + operations: { + list: { + method: "POST", + path: `${RMG}/release/list`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + queryParams: { + page: "page", + size: "size", + search_term: "searchTerm", + start_ts: "expectedStartTs", + end_ts: "expectedEndTs", + }, + staticQueryParams: { type: "Orchestration" }, + skipScopeBodyInjection: true, + preflight: releaseListPreflight, + bodyBuilder: releaseListBody, + responseExtractor: releaseListExtract, + description: + "List releases for the current scope within an expected-start time window, " + + "optionally filtered by search_term and status. Status matching is client-side on the current page; " + + "when status is set the response includes _hint and a page-local total.", + }, + get: { + method: "GET", + path: `${RMG}/release/{releaseId}`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + pathParams: { release_id: "releaseId" }, + responseExtractor: releaseGetExtract, + description: + "Get release details by release_id (UUID from harness_list, or UI URL slug like identifier-1.0.0-abc)", + }, + }, + }, + { + resourceType: "release_execution_phase", + displayName: "Release Execution Phase", + description: + "An execution phase within a release (status, activity progress, timestamps). Requires release_id " + + "(from harness_list release `id`, UI URL slug, or filters.release_id). Paste an RMG phases URL to auto-fill.", + toolset: "release-management", + scope: "project", + supportedScopes: ["account", "org", "project"], + scopeOptional: true, + headerBasedScoping: true, + baseUrlOverride: "rmg", + identifierFields: [], + searchAliases: ["release phase", "execution phase", "rmg phase"], + listFilterFields: [ + { + name: "release_id", + required: true, + description: "Release id or UI slug (from harness_list `id` or RMG URL path segment)", + }, + { + name: "status", + description: "Optional filter by phase status", + }, + ], + relatedResources: [ + { + resourceType: "release", + relationship: "belongs_to", + description: "Parent release instance", + }, + { + resourceType: "release_execution_phase_input", + relationship: "consumes_inputs", + description: "Get phase inputs via harness_get resource_type=release_execution_phase_input", + }, + { + resourceType: "release_execution_phase_output", + relationship: "produces_outputs", + description: "Get phase outputs via harness_get resource_type=release_execution_phase_output", + }, + ], + operations: { + list: { + method: "GET", + path: `${RMG}/orchestration/execution/{releaseId}/phases`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + pathParams: { release_id: "releaseId" }, + queryParams: { status: "status" }, + responseExtractor: releasePhaseListExtract, + description: "List execution phases for a release (requires release_id)", + }, + }, + }, + { + resourceType: "release_execution_task", + displayName: "Release Execution Task", + description: + "Manual execution tasks for a release (TODO, IN_PROGRESS, SUCCEEDED, FAILED, BLOCKED). Requires release_id " + + "(UUID from harness_list release). Use harness_list with filters.release_id; optional status and limit filters.", + toolset: "release-management", + scope: "project", + supportedScopes: ["account", "org", "project"], + scopeOptional: true, + headerBasedScoping: true, + baseUrlOverride: "rmg", + identifierFields: ["release_id"], + searchAliases: ["release task", "manual task", "execution task"], + listFilterFields: [ + { + name: "release_id", + required: true, + description: "Release id or UI slug (from harness_list `id` or RMG URL path segment)", + }, + { + name: "status", + description: "Filter tasks by status", + enum: [...TASK_STATUSES], + }, + { + name: "limit", + type: "number", + description: `Max tasks to return (default ${RMG_DEFAULT_TASK_LIMIT}, max ${RMG_MAX_TASK_LIMIT}; harness_list size also accepted)`, + }, + { + name: "cursor", + description: "Cursor for the next page (from previous response pagination.next_cursor)", + }, + ], + relatedResources: [ + { + resourceType: "release", + relationship: "belongs_to", + description: "Parent release instance", + }, + ], + operations: { + list: { + method: "GET", + path: `${RMG}/release/{releaseId}/tasks`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + pathParams: { release_id: "releaseId" }, + queryParams: { + limit: "limit", + status: "status", + cursor: "cursor", + }, + preflight: async ({ input }) => { + normalizeReleaseTaskLimit(input); + }, + responseExtractor: releaseTaskListExtract, + description: "List manual tasks for a release (requires release_id)", + }, + }, + }, + { + resourceType: "release_execution_activity", + displayName: "Release Activity Execution", + description: + "Runtime activity executions within a release (pipeline, subprocess, or manual steps). Requires release_id. " + + "Supports pagination and filters: status (comma-separated OR), activity_type, phase_identifier, time range. " + + "Default sort: start_ts desc. Search aliases: release activity run, execution activity.", + toolset: "release-management", + scope: "project", + supportedScopes: ["account", "org", "project"], + scopeOptional: true, + headerBasedScoping: true, + baseUrlOverride: "rmg", + identifierFields: ["release_id"], + searchAliases: ["release activity execution", "execution activity", "activity run"], + listFilterFields: [ + { + name: "release_id", + required: true, + description: "Release id or UI slug (from harness_list `id` or RMG URL path segment)", + }, + { + name: "status", + description: "Comma-separated statuses (OR logic)", + enum: [...ACTIVITY_EXECUTION_STATUSES], + }, + { + name: "activity_type", + description: "Comma-separated activity types", + enum: [...ACTIVITY_EXECUTION_TYPES], + }, + { + name: "phase_identifier", + description: "Filter to activities in a specific phase", + }, + { + name: "sort_field", + description: "Sort field", + enum: [...ACTIVITY_SORT_FIELDS], + }, + { + name: "sort_direction", + description: "Sort direction", + enum: ["asc", "desc"], + }, + { + name: "start_ts", + type: "number", + description: "Include activities with start_ts >= this value (epoch ms)", + }, + { + name: "end_ts", + type: "number", + description: "Include activities with start_ts <= this value (epoch ms)", + }, + ], + relatedResources: [ + { + resourceType: "release", + relationship: "belongs_to", + description: "Parent release instance", + }, + { + resourceType: "release_execution_phase", + relationship: "in_phase", + description: "Filter by phase_identifier from release_execution_phase list", + }, + { + resourceType: "release_execution_activity_output", + relationship: "produces_outputs", + description: "Get activity outputs via harness_get resource_type=release_execution_activity_output", + }, + { + resourceType: "release_execution_activity_input", + relationship: "consumes_inputs", + description: + "Get activity inputs via harness_get resource_type=release_execution_activity_input " + + "(requires activityExecutionId from list item, not YAML activity identifier)", + }, + ], + operations: { + list: { + method: "GET", + path: `${RMG}/release/{releaseId}/execution/activities`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + pathParams: { release_id: "releaseId" }, + queryParams: { + page: "page", + size: "size", + sort: "sort", + status: "status", + activity_type: "type", + phase_identifier: "phaseIdentifier", + start_ts: "startTs", + end_ts: "endTs", + }, + preflight: async ({ input }) => { + normalizeReleaseActivityExecutionInput(input); + }, + responseExtractor: releaseActivityExecutionListExtract, + description: + "List paginated activity executions for a release. Default page=0, size=20, sort=start_ts desc.", + }, + }, + }, + { + resourceType: "release_execution_phase_output", + displayName: "Release Execution Phase Output", + description: + "Execution outputs (variables, artifacts, runtime data) for a release phase. " + + "Use harness_get with resource_id=release id/slug and params.phase_identifier.", + toolset: "release-management", + scope: "project", + supportedScopes: ["account", "org", "project"], + scopeOptional: true, + headerBasedScoping: true, + baseUrlOverride: "rmg", + identifierFields: ["release_id"], + searchAliases: ["release phase output", "phase output", "execution variable"], + relatedResources: [ + { + resourceType: "release", + relationship: "belongs_to", + description: "Parent release instance", + }, + { + resourceType: "release_execution_phase", + relationship: "from_phase", + description: "phase_identifier comes from release_execution_phase list", + }, + ], + operations: { + get: { + method: "GET", + path: `${RMG}/orchestration/execution/release/{releaseId}/phase/{phaseIdentifier}/output`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + pathBuilder: (input) => releaseExecutionPhaseOutputPath(input), + queryParams: { phase_execution_id: "phaseExecutionId" }, + paramsSchema: releaseExecutionPhaseOutputParamsSchema, + responseExtractor: releasePhaseOutputGetExtract, + description: + "Get execution outputs for a phase. resource_id=release id/slug; params.phase_identifier required. " + + "Optional params.phase_execution_id disambiguates multiple phase execution records.", + }, + }, + }, + { + resourceType: "release_execution_phase_input", + displayName: "Release Execution Phase Input", + description: + "Runtime input values for a release phase execution. Use harness_get with resource_id=release id/slug " + + "and params.phase_identifier (YAML identifier from harness_list release_execution_phase). " + + "Optional params.phase_execution_id pins a specific phase execution row.", + toolset: "release-management", + scope: "project", + supportedScopes: ["account", "org", "project"], + scopeOptional: true, + headerBasedScoping: true, + baseUrlOverride: "rmg", + identifierFields: ["release_id"], + searchAliases: ["release phase input", "phase input", "execution input"], + relatedResources: [ + { + resourceType: "release", + relationship: "belongs_to", + description: "Parent release instance", + }, + { + resourceType: "release_execution_phase", + relationship: "from_phase", + description: "phase_identifier comes from release_execution_phase list", + }, + ], + operations: { + get: { + method: "GET", + path: `${RMG}/orchestration/execution/release/{releaseId}/phase/{phaseIdentifier}/input`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + pathBuilder: (input) => releaseExecutionPhaseInputPath(input), + queryParams: { phase_execution_id: "phaseExecutionId" }, + paramsSchema: releaseExecutionPhaseOutputParamsSchema, + responseExtractor: releasePhaseInputGetExtract, + description: + "Get execution inputs for a phase. resource_id=release id/slug; params.phase_identifier required. " + + "Optional params.phase_execution_id disambiguates multiple phase execution records.", + }, + }, + }, + { + resourceType: "release_execution_activity_output", + displayName: "Release Execution Activity Output", + description: + "Execution outputs for a specific activity within a release phase. " + + "Use harness_get with resource_id=release id/slug, params.phase_identifier, and params.activity_identifier " + + "(YAML identifiers from release_execution_activity list).", + toolset: "release-management", + scope: "project", + supportedScopes: ["account", "org", "project"], + scopeOptional: true, + headerBasedScoping: true, + baseUrlOverride: "rmg", + identifierFields: ["release_id"], + searchAliases: ["release activity output", "activity output", "execution variable"], + relatedResources: [ + { + resourceType: "release", + relationship: "belongs_to", + description: "Parent release instance", + }, + { + resourceType: "release_execution_activity", + relationship: "from_activity", + description: "activity_identifier comes from release_execution_activity list", + }, + ], + operations: { + get: { + method: "GET", + path: `${RMG}/orchestration/execution/release/{releaseId}/phase/{phaseIdentifier}/activity/{activityIdentifier}/output`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + pathBuilder: (input) => releaseExecutionActivityOutputPath(input), + queryParams: { activity_execution_id: "activityExecutionId" }, + paramsSchema: releaseExecutionActivityOutputParamsSchema, + responseExtractor: releaseActivityOutputGetExtract, + description: + "Get execution outputs for an activity. resource_id=release id/slug; " + + "params.phase_identifier and params.activity_identifier required. " + + "Optional params.activity_execution_id pins a specific runtime activity row.", + }, + }, + }, + { + resourceType: "release_input", + displayName: "Release Execution Input", + description: + "Latest release input YAML for a release (global inputs). For per-phase runtime inputs use " + + "harness_get resource_type=release_execution_phase_input with release id/slug and params.phase_identifier.", + toolset: "release-management", + scope: "project", + supportedScopes: ["account", "org", "project"], + scopeOptional: true, + headerBasedScoping: true, + baseUrlOverride: "rmg", + identifierFields: ["release_id"], + searchAliases: ["release input", "release input yaml", "execution input"], + relatedResources: [ + { + resourceType: "release", + relationship: "belongs_to", + description: "Parent release instance", + }, + ], + operations: { + get: { + method: "GET", + path: `${RMG}/orchestration/execution/releaseInput/{releaseId}`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + pathParams: { release_id: "releaseId" }, + responseExtractor: releaseInputGetExtract, + description: + "Get latest release input YAML. resource_id=release id/slug. Returns release_id, process_execution_id, yaml.", + }, + }, + }, + { + resourceType: "release_execution_activity_input", + displayName: "Release Execution Activity Input", + description: + "Runtime input values for an activity execution. Requires activityExecutionId (UUID) from " + + "harness_list release_execution_activity — not the YAML activity identifier.", + toolset: "release-management", + scope: "project", + supportedScopes: ["account", "org", "project"], + scopeOptional: true, + headerBasedScoping: true, + baseUrlOverride: "rmg", + identifierFields: ["activity_execution_id"], + searchAliases: ["release activity input", "activity input", "execution input"], + relatedResources: [ + { + resourceType: "release_execution_activity", + relationship: "from_activity", + description: "activityExecutionId comes from release_execution_activity list item", + }, + ], + operations: { + get: { + method: "GET", + path: `${RMG}/orchestration/execution/activity/{activityExecutionId}/input`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + pathParams: { activity_execution_id: "activityExecutionId" }, + responseExtractor: releaseActivityInputGetExtract, + description: + "Get activity execution inputs. resource_id=activityExecutionId from list item field activityExecutionId.", + }, + }, + }, + ], +}; diff --git a/src/registry/types.ts b/src/registry/types.ts index 64e185b98..a68960686 100644 --- a/src/registry/types.ts +++ b/src/registry/types.ts @@ -153,6 +153,7 @@ export type ToolsetName = | "ai-evals" | "incidents" | "deploys" + | "release-management" | "knowledge-graph" | "semantic-layer"; @@ -476,7 +477,7 @@ export interface ResourceDefinition { * migrating to plain Harness-native ("harness") routing per-call. */ product?: ProductName; - baseUrlOverride?: "fme"; + baseUrlOverride?: "fme" | "rmg"; /** * When true, this resource uses header-based scoping (Harness-Account header) * instead of the standard `accountIdentifier` query param. Also prevents diff --git a/src/resources/harness-schema.ts b/src/resources/harness-schema.ts index efcb91476..f4107617b 100644 --- a/src/resources/harness-schema.ts +++ b/src/resources/harness-schema.ts @@ -47,7 +47,8 @@ export function registerHarnessSchemaResource( description: `Harness JSON Schema definitions (bundled). Valid schema names: ${allSchemaNames.join(", ")}. ` + "For connector, environment, service, secret, and infrastructure schemas, use the harness_schema tool " + - "(live fetch from NG /yaml-schema) — they are not available via schema:/// resources.", + "(live fetch from NG /yaml-schema). For release_process and release_activity, use harness_schema " + + "(live fetch from RMG /api/yamlSchema) — they are not available via schema:/// resources.", mimeType: "application/schema+json", }, async (uri) => { diff --git a/src/tools/entity-schema/live.ts b/src/tools/entity-schema/live.ts index 34d32f428..a7c3fc9af 100644 --- a/src/tools/entity-schema/live.ts +++ b/src/tools/entity-schema/live.ts @@ -1,4 +1,5 @@ import type { HarnessClient } from "../../client/harness-client.js"; +import { resolveRmgBaseUrl, type Config } from "../../config.js"; import { createLogger } from "../../utils/logger.js"; import { normalizeEntitySchema, @@ -47,6 +48,18 @@ export const LIVE_ENTITY_SCHEMAS: Record = { entityType: "Infrastructure", description: "Infrastructure entity schema from NG yaml-schema API", }, + release_process: { + entityType: "PROCESS", + api: "rmg", + rootProperty: "process", + description: "Release orchestration process YAML schema from RMG /api/yamlSchema", + }, + release_activity: { + entityType: "ACTIVITY", + api: "rmg", + rootProperty: "activity", + description: "Release orchestration activity YAML schema from RMG /api/yamlSchema", + }, }; export const LIVE_ENTITY_RESOURCE_TYPES = Object.keys(LIVE_ENTITY_SCHEMAS); @@ -179,6 +192,40 @@ function buildYamlSchemaParams( return query; } +/** Build RMG /api/yamlSchema query params (org/project only — account via Harness-Account header). */ +function buildRmgYamlSchemaParams(params: LiveSchemaFetchParams): Record { + const scope = resolveScope(params.scope); + const query: Record = {}; + + if (scope === "org" || scope === "project") { + if (!params.orgId) { + throw new Error(`org_id is required when scope is '${scope}'`); + } + query.orgIdentifier = params.orgId; + } + + if (scope === "project") { + if (!params.projectId) { + throw new Error("project_id is required when scope is 'project'"); + } + query.projectIdentifier = params.projectId; + } + + return query; +} + +function validateLiveSchemaParams( + resourceType: string, + definition: LiveEntitySchemaDefinition, + params: LiveSchemaFetchParams, +): void { + if ((definition.api ?? "ng") === "rmg") { + buildRmgYamlSchemaParams(params); + return; + } + buildYamlSchemaParams(resourceType, definition, params); +} + function getNestedSchemaRoot(value: unknown, preferredKey: string): JsonObject | undefined { if (!isRecord(value)) return undefined; if (looksLikeJsonSchema(value) && "properties" in value) return value; @@ -219,7 +266,16 @@ export function getResourceDefinitions( return undefined; } -export function getRootDefinition(schema: JsonObject, resourceType: string): JsonObject | undefined { +export function getRootDefinition( + schema: JsonObject, + resourceType: string, + rootProperty?: string, +): JsonObject | undefined { + if (rootProperty) { + const wrapper = (schema.properties as JsonObject | undefined)?.[rootProperty]; + if (looksLikeJsonSchema(wrapper)) return wrapper as JsonObject; + } + const resourceDefs = getResourceDefinitions(schema, resourceType); const exactHarnessRoot = resourceDefs?.[resourceType]; if (looksLikeJsonSchema(exactHarnessRoot)) return exactHarnessRoot; @@ -239,7 +295,16 @@ export function getRootDefinition(schema: JsonObject, resourceType: string): Jso return undefined; } -export function getDefinitionSections(schema: JsonObject, resourceType: string): string[] { +export function getDefinitionSections( + schema: JsonObject, + resourceType: string, + rootProperty?: string, +): string[] { + const rootDef = getRootDefinition(schema, resourceType, rootProperty); + if (rootProperty && rootDef?.properties) { + return Object.keys(rootDef.properties as Record); + } + const resourceDefs = getResourceDefinitions(schema, resourceType); if (resourceDefs) return Object.keys(resourceDefs); @@ -251,9 +316,10 @@ export function getEntitySchemaSummary( schema: JsonObject, resourceType: string, source: EntitySchemaSource, + rootProperty?: string, ): Record { - const sections = getDefinitionSections(schema, resourceType); - const rootDef = getRootDefinition(schema, resourceType); + const sections = getDefinitionSections(schema, resourceType, rootProperty); + const rootDef = getRootDefinition(schema, resourceType, rootProperty); const properties = rootDef?.properties as Record | undefined; const required = rootDef?.required as string[] | undefined; @@ -273,13 +339,16 @@ export function getEntitySchemaSummary( const hint = source === "bundled" ? "Schema from vendored snapshot (pnpm sync-entity-schemas). Use scope, org_id, and project_id for org/project entities." - : "Schema from live NG /ng/api/yaml-schema. Pass scope, org_id, and project_id for org/project scoped entities."; + : source === "rmg-yaml-schema" + ? "Schema from live RMG /api/yamlSchema. Pass scope, org_id, and project_id when scoping to org or project." + : "Schema from live NG /ng/api/yaml-schema. Pass scope, org_id, and project_id for org/project scoped entities."; return { resource_type: resourceType, source, fields, available_sections: sections, + ...(rootProperty ? { yaml_root: rootProperty } : {}), hint: `Use path to drill into a section. ${hint}`, }; } @@ -288,7 +357,36 @@ export function navigateEntitySchemaPath( schema: JsonObject, resourceType: string, path: string, + rootProperty?: string, ): unknown { + if (rootProperty) { + const rootDef = getRootDefinition(schema, resourceType, rootProperty); + if (!rootDef) return undefined; + + if (path === rootProperty) return rootDef; + + const normalizedPath = path.startsWith(`${rootProperty}.`) + ? path.slice(rootProperty.length + 1) + : path; + const parts = normalizedPath.split(".").filter(Boolean); + let current: unknown = rootDef; + for (const part of parts) { + if (!current || typeof current !== "object" || Array.isArray(current)) return undefined; + const obj = current as JsonObject; + const props = obj.properties as JsonObject | undefined; + if (props && part in props) { + current = props[part]; + continue; + } + if (part in obj) { + current = obj[part]; + continue; + } + return undefined; + } + return current; + } + const resourceDefs = getResourceDefinitions(schema, resourceType); if (resourceDefs) { if (resourceDefs[path]) return resourceDefs[path]; @@ -341,7 +439,7 @@ function logBundledServe( }); } -export function createLiveSchemaFetcher(client: HarnessClient): LiveSchemaFetcher { +export function createLiveSchemaFetcher(client: HarnessClient, config?: Config): LiveSchemaFetcher { const cache = new Map(); preloadBundledEntitySchemas(cache, client.account); @@ -363,8 +461,8 @@ export function createLiveSchemaFetcher(client: HarnessClient): LiveSchemaFetche const accountId = client.account; const scope = resolveScope(params.scope); - // Validate scope/org/project before cache or bundled paths (same rules as live NG fetch). - buildYamlSchemaParams(resourceType, definition, params); + const api = definition.api ?? "ng"; + validateLiveSchemaParams(resourceType, definition, params); const cacheKey = buildLiveSchemaCacheKey(resourceType, accountId, scope, { orgId: params.orgId, @@ -380,6 +478,36 @@ export function createLiveSchemaFetcher(client: HarnessClient): LiveSchemaFetche return cached; } + if (api === "rmg") { + if (!config) { + throw new Error("RMG YAML schema fetch requires server configuration."); + } + + const scopeParams = buildRmgYamlSchemaParams(params); + log.debug("Fetching RMG YAML schema", { + resource_type: resourceType, + entity_type: definition.entityType, + scope, + account_id: accountId, + }); + + const response = await client.request({ + method: "GET", + path: "/api/yamlSchema", + baseUrl: resolveRmgBaseUrl(config), + headerBasedScoping: true, + params: { + entityType: definition.entityType, + ...scopeParams, + }, + }); + + const raw = extractLiveSchema(response); + const entry: EntitySchemaCacheEntry = { schema: raw, source: "rmg-yaml-schema" }; + cache.set(cacheKey, entry); + return entry; + } + // Explicit identifier requests entity-specific schema — bundled snapshots are type-level only. const bundled = params.identifier ? undefined : getBundledEntitySchema(resourceType, scope); if ( diff --git a/src/tools/entity-schema/types.ts b/src/tools/entity-schema/types.ts index 1daa0f51f..062e56f44 100644 --- a/src/tools/entity-schema/types.ts +++ b/src/tools/entity-schema/types.ts @@ -1,11 +1,18 @@ export type HarnessYamlScope = "account" | "org" | "project"; /** Where an entity schema was loaded from at runtime. */ -export type EntitySchemaSource = "bundled" | "ng-yaml-schema"; +export type EntitySchemaSource = "bundled" | "ng-yaml-schema" | "rmg-yaml-schema"; + +/** Backend that serves live YAML JSON Schema for a resource type. */ +export type LiveEntitySchemaApi = "ng" | "rmg"; export interface LiveEntitySchemaDefinition { entityType: string; description: string; + /** Default `ng` — Harness NG `/ng/api/yaml-schema`. `rmg` uses Release Management `/api/yamlSchema`. */ + api?: LiveEntitySchemaApi; + /** Top-level YAML wrapper key in RMG schemas (e.g. `process`, `activity`). */ + rootProperty?: string; } export interface LiveSchemaFetchParams { diff --git a/src/tools/harness-list.ts b/src/tools/harness-list.ts index 23f0a31b0..7427fa580 100644 --- a/src/tools/harness-list.ts +++ b/src/tools/harness-list.ts @@ -66,6 +66,17 @@ export function registerListTool(server: McpServer, registry: Registry, client: if (resourceType === "template" && input.template_list_type === undefined) { input.template_list_type = "All"; } + // Map URL-derived resource_id onto required parent list filters (e.g. RMG release_id). + if (!input.release_id && input.resource_id) { + try { + const def = registry.getResource(resourceType); + if (def.listFilterFields?.some((f) => f.name === "release_id" && f.required)) { + input.release_id = input.resource_id; + } + } catch { + /* resource_type validated below */ + } + } const rawResult = await registry.dispatch(client, resourceType, "list", input); const page = typeof args.page === "number" ? args.page : 0; const result = normalizeHarnessListPayload(rawResult, { page }); diff --git a/src/tools/harness-schema.ts b/src/tools/harness-schema.ts index 3ed4bd829..501acb058 100644 --- a/src/tools/harness-schema.ts +++ b/src/tools/harness-schema.ts @@ -8,10 +8,12 @@ import type { SchemaEntry } from "../data/schemas/types.js"; import { getExample, searchExamples, getExamplesForResource } from "../data/examples/index.js"; import { createLogger } from "../utils/logger.js"; import { schemaOutputSchema } from "./output-schemas.js"; +import type { Config } from "../config.js"; import { createLiveSchemaFetcher, getDefinitionSections, getEntitySchemaSummary, + LIVE_ENTITY_SCHEMAS, navigateEntitySchemaPath, LIVE_ENTITY_RESOURCE_TYPES, type HarnessYamlScope, @@ -24,8 +26,8 @@ const scopeSchema = z .enum(["account", "org", "project"]) .optional() .describe( - "Harness YAML scope for live entity schemas (connector, environment, service, secret, infrastructure). " + - "Defaults to account. Org/project scoped entities require org_id and/or project_id.", + "Harness YAML scope for live entity schemas (connector, environment, service, secret, infrastructure, " + + "release_process, release_activity). Defaults to account. Org/project scoped entities require org_id and/or project_id.", ); function resolveRef(schema: Record, ref: string): unknown { @@ -217,6 +219,7 @@ export function registerSchemaTool( server: McpServer, _registry: Registry | undefined, client: HarnessClient | undefined, + config: Config | undefined, additionalSchemas?: Record, ): void { if (additionalSchemas) { @@ -234,7 +237,7 @@ export function registerSchemaTool( } : { ...SCHEMAS }; - const liveFetcher = client ? createLiveSchemaFetcher(client) : undefined; + const liveFetcher = client ? createLiveSchemaFetcher(client, config) : undefined; const availableSchemas = listAvailableSchemaNames(Object.keys(allSchemas), liveFetcher); const hasLiveEntities = liveFetcher !== undefined; @@ -245,6 +248,7 @@ export function registerSchemaTool( "Fetch Harness YAML schema or examples for a resource type. " + "Pipeline/template schemas are bundled from harness-schema; connector, environment, service, " + "secret, and infrastructure schemas are fetched live from NG /yaml-schema (pass scope, org_id, project_id). " + + "release_process and release_activity schemas are fetched live from RMG /api/yamlSchema. " + "Use without path for a summary of fields and available sections. " + "Use with path to drill into a specific section. " + "Use with example to fetch a named example YAML snippet. " + @@ -372,14 +376,15 @@ export function registerSchemaTool( } const { schema, source } = fetched; + const rootProperty = LIVE_ENTITY_SCHEMAS[args.resource_type]?.rootProperty; if (!args.path) { - return jsonResult(getEntitySchemaSummary(schema, args.resource_type, source)); + return jsonResult(getEntitySchemaSummary(schema, args.resource_type, source, rootProperty)); } - const node = navigateEntitySchemaPath(schema, args.resource_type, args.path); + const node = navigateEntitySchemaPath(schema, args.resource_type, args.path, rootProperty); if (!node) { - const available = getDefinitionSections(schema, args.resource_type); + const available = getDefinitionSections(schema, args.resource_type, rootProperty); return errorResult( `Path '${args.path}' not found in ${args.resource_type} schema. ` + `Available sections: ${available.join(", ")}`, diff --git a/src/tools/index.ts b/src/tools/index.ts index 15a4a1790..d9da0cb3c 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -30,5 +30,5 @@ export function registerAllTools(server: McpServer, registry: Registry, client: registerSearchTool(server, registry, client, searchManager); registerDescribeTool(server, registry); registerStatusTool(server, registry, client, config); - registerSchemaTool(server, registry, client, additionalSchemas); + registerSchemaTool(server, registry, client, config, additionalSchemas); } diff --git a/src/utils/url-parser.ts b/src/utils/url-parser.ts index 241374117..2f1b03aef 100644 --- a/src/utils/url-parser.ts +++ b/src/utils/url-parser.ts @@ -27,6 +27,8 @@ export interface ParsedHarnessUrl { store_type?: string; connector_ref?: string; repo_name?: string; + /** RMG release id — UUID from search or UI URL slug (identifier-version hash). */ + release_id?: string; } /** Union of ParsedHarnessUrl fields that RESOURCE_SEGMENTS can write to. */ @@ -218,6 +220,37 @@ export function parseHarnessUrl(urlStr: string): ParsedHarnessUrl { } } + // 7. RMG release-management URLs: + // .../release-management/releases/{slug}/execution/phases|tasks|activities + const rmgRootIdx = segments.indexOf("release-management"); + if (rmgRootIdx >= 0) { + const releasesIdx = segments.indexOf("releases", rmgRootIdx); + if (releasesIdx >= 0 && releasesIdx + 1 < segments.length) { + const releaseSlug = decodeURIComponent(segments[releasesIdx + 1]!); + if (releaseSlug && !STRUCTURAL.has(releaseSlug) && releaseSlug !== "execution") { + result.release_id = releaseSlug; + result.resource_id = releaseSlug; + + const suffix = segments.slice(releasesIdx + 2); + if (suffix.includes("phases")) { + result.resource_type = "release_execution_phase"; + } else if (suffix.includes("tasks")) { + result.resource_type = "release_execution_task"; + } else if (suffix.includes("activities")) { + result.resource_type = "release_execution_activity"; + } else { + result.resource_type = "release"; + } + + if (result.project_id) { + result.resource_scope = "project"; + } else if (result.org_id) { + result.resource_scope = "org"; + } + } + } + } + const stepId = url.searchParams.get("step") ?? url.searchParams.get("stepId"); if (stepId) result.step_id = stepId; @@ -268,6 +301,7 @@ const MERGEABLE_FIELDS: (keyof ParsedHarnessUrl)[] = [ "store_type", "connector_ref", "repo_name", + "release_id", ]; export interface ApplyUrlDefaultsOptions { diff --git a/tests/config.test.ts b/tests/config.test.ts index 1756e7051..0e0fe2b67 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -5,6 +5,8 @@ import { isPlaceholderCredential, loadConfig, resolveFmeApiKey, + resolveResourceBaseUrl, + resolveRmgBaseUrl, } from "../src/config.js"; describe("extractAccountIdFromToken", () => { @@ -505,6 +507,14 @@ describe("ConfigSchema — HTTPS enforcement", () => { } }); + it("resolves RMG base URL from HARNESS_BASE_URL/gateway/rmg", () => { + const result = ConfigSchema.parse(validConfig); + expect(resolveRmgBaseUrl(result)).toBe("https://app.harness.io/gateway/rmg"); + expect(resolveResourceBaseUrl(result, { baseUrlOverride: "rmg" })).toBe( + "https://app.harness.io/gateway/rmg", + ); + }); + it("resolves FME auth from explicit key before Harness API key", () => { expect(resolveFmeApiKey({ HARNESS_MCP_MODE: "single-user", diff --git a/tests/registry/toolsets/release-management.test.ts b/tests/registry/toolsets/release-management.test.ts new file mode 100644 index 000000000..085f43c0e --- /dev/null +++ b/tests/registry/toolsets/release-management.test.ts @@ -0,0 +1,398 @@ +import { describe, expect, it } from "vitest"; +import { releaseManagementToolset } from "../../../src/registry/toolsets/release-management.js"; +import { Registry } from "../../../src/registry/index.js"; +import type { Config } from "../../../src/config.js"; +import { + releaseGetExtract, + rmgYamlEntityExtract, + rmgYamlEntityDeleteExtract, + yamlWriteBody, +} from "../../../src/registry/extractors.js"; + +function makeConfig(overrides: Partial = {}): Config { + return { + HARNESS_API_KEY: "pat.test.abc.xyz", + HARNESS_ACCOUNT_ID: "test-account", + HARNESS_BASE_URL: "https://app.harness.io", + HARNESS_ORG: "default", + HARNESS_PROJECT: "test-project", + HARNESS_API_TIMEOUT_MS: 30000, + HARNESS_MAX_RETRIES: 3, + LOG_LEVEL: "info", + HARNESS_AUTO_APPROVE_RISK: "none", + ...overrides, + } as Config; +} + +function resource(type: string) { + const r = releaseManagementToolset.resources.find((x) => x.resourceType === type); + if (!r) throw new Error(`${type} missing from release-management toolset`); + return r; +} + +const releaseResource = resource("release"); +const releaseProcessResource = resource("release_process"); +const releaseDefinitionActivityResource = resource("release_activity"); +const releasePhaseResource = resource("release_execution_phase"); +const releaseTaskResource = resource("release_execution_task"); +const releaseActivityResource = resource("release_execution_activity"); +const phaseOutputResource = resource("release_execution_phase_output"); +const phaseInputResource = resource("release_execution_phase_input"); +const activityOutputResource = resource("release_execution_activity_output"); +const releaseInputResource = resource("release_input"); +const activityInputResource = resource("release_execution_activity_input"); + +const buildReleaseListBody = releaseResource.operations.list!.bodyBuilder!; +const releaseListPreflight = releaseResource.operations.list!.preflight!; +const fillScopeFromConfig = releaseProcessResource.operations.list!.preflight!; +const extractReleaseList = releaseResource.operations.list!.responseExtractor!; +const extractPhases = releasePhaseResource.operations.list!.responseExtractor!; +const extractTasks = releaseTaskResource.operations.list!.responseExtractor!; +const buildPhaseOutputPath = phaseOutputResource.operations.get!.pathBuilder!; +const buildPhaseInputPath = phaseInputResource.operations.get!.pathBuilder!; +const buildActivityOutputPath = activityOutputResource.operations.get!.pathBuilder!; +const extractPhaseOutput = phaseOutputResource.operations.get!.responseExtractor!; +const extractPhaseInput = phaseInputResource.operations.get!.responseExtractor!; +const extractActivityOutput = activityOutputResource.operations.get!.responseExtractor!; +const extractReleaseInput = releaseInputResource.operations.get!.responseExtractor!; +const extractActivityInput = activityInputResource.operations.get!.responseExtractor!; +const normalizeActivityPreflight = releaseActivityResource.operations.list!.preflight!; +const normalizeTaskPreflight = releaseTaskResource.operations.list!.preflight!; + +describe("release-management toolset", () => { + it("is loaded by default (not opt-in)", () => { + expect(releaseManagementToolset.optIn).toBe(false); + }); + + it("is present when HARNESS_TOOLSETS is unset (all defaults)", () => { + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: undefined })); + expect(registry.getAllResourceTypes()).toContain("release_process"); + expect(registry.getAllResourceTypes()).toContain("release"); + }); +}); + +describe("release-management execution resources", () => { + it("registers definition and execution resource types", () => { + expect(releaseManagementToolset.resources.map((r) => r.resourceType)).toEqual( + expect.arrayContaining([ + "release_process", + "release_activity", + "release_execution_phase", + "release_execution_task", + "release_execution_activity", + "release_execution_phase_output", + "release_execution_phase_input", + "release_execution_activity_output", + "release_input", + "release_execution_activity_input", + ]), + ); + }); + + it("definition resources preflight fills org/project from registry defaults", async () => { + const input: Record = {}; + await fillScopeFromConfig({ + client: {} as never, + input, + registry: { orgId: "cfgOrg", projectId: "cfgProject" } as never, + }); + expect(input.org_id).toBe("cfgOrg"); + expect(input.project_id).toBe("cfgProject"); + }); + + it("release_process and release_activity use RMG base override and header scoping", () => { + expect(releaseProcessResource.baseUrlOverride).toBe("rmg"); + expect(releaseProcessResource.headerBasedScoping).toBe(true); + expect(releaseDefinitionActivityResource.baseUrlOverride).toBe("rmg"); + expect(releaseDefinitionActivityResource.headerBasedScoping).toBe(true); + }); + + it("release_process has no execute action", () => { + expect(releaseProcessResource.executeActions).toBeUndefined(); + expect(releaseProcessResource.executeHint).toBeUndefined(); + }); + + it("definition resources use rmgYamlEntityExtract instead of passthrough", () => { + for (const op of ["get", "create", "update"] as const) { + expect(releaseProcessResource.operations[op]?.responseExtractor).toBe(rmgYamlEntityExtract); + expect(releaseDefinitionActivityResource.operations[op]?.responseExtractor).toBe(rmgYamlEntityExtract); + } + expect(releaseProcessResource.operations.delete?.responseExtractor).toBe(rmgYamlEntityDeleteExtract); + expect(releaseDefinitionActivityResource.operations.delete?.responseExtractor).toBe(rmgYamlEntityDeleteExtract); + }); + + it("release_execution_phase is list-only with empty identifierFields", () => { + expect(releasePhaseResource.operations.get).toBeUndefined(); + expect(releasePhaseResource.identifierFields).toEqual([]); + expect(releasePhaseResource.listFilterFields).toContainEqual( + expect.objectContaining({ name: "release_id", required: true }), + ); + }); + + it("release_execution_phase list hits GET /api/orchestration/execution/{releaseId}/phases", () => { + expect(releasePhaseResource.operations.list?.method).toBe("GET"); + expect(releasePhaseResource.operations.list?.path).toBe( + "/api/orchestration/execution/{releaseId}/phases", + ); + }); + + it("release_execution_activity list hits paginated activities path", () => { + expect(releaseActivityResource.operations.list?.method).toBe("GET"); + expect(releaseActivityResource.operations.list?.path).toBe( + "/api/release/{releaseId}/execution/activities", + ); + }); + + it("release list hits POST /api/release/list with the Orchestration type param", () => { + expect(releaseResource.operations.list?.method).toBe("POST"); + expect(releaseResource.operations.list?.path).toBe("/api/release/list"); + expect(releaseResource.operations.list?.staticQueryParams).toEqual({ type: "Orchestration" }); + expect(releaseResource.operations.list?.queryParams).toMatchObject({ + search_term: "searchTerm", + start_ts: "expectedStartTs", + end_ts: "expectedEndTs", + }); + }); + + it("release list body carries scope in scopes, not query params", () => { + expect( + buildReleaseListBody({ org_id: "org1", project_id: "proj1" }), + ).toEqual({ scopes: [{ orgIdentifier: "org1", projectIdentifier: "proj1" }] }); + }); + + it("release list preflight fills scope from config defaults and builds the time window", async () => { + const input: Record = { days_back: 90 }; + await releaseListPreflight({ + client: {} as never, + input, + registry: { orgId: "cfgOrg", projectId: "cfgProject" } as never, + }); + expect(input.org_id).toBe("cfgOrg"); + expect(input.project_id).toBe("cfgProject"); + expect(Number(input.end_ts) - Number(input.start_ts)).toBe(97 * 86_400_000); + }); + + it("release list preflight keeps explicit start_ts/end_ts", async () => { + const input: Record = { start_ts: 1, end_ts: 2, days_back: 90 }; + await releaseListPreflight({ client: {} as never, input, registry: {} as never }); + expect(input.start_ts).toBe(1); + expect(input.end_ts).toBe(2); + }); + + it("release list extractor reads Spring page and legacy releases payloads", () => { + expect( + (extractReleaseList({ content: [{ id: "r1" }], totalElements: 7 }) as { items: unknown[]; total: number }), + ).toEqual({ items: [{ id: "r1" }], total: 7 }); + expect( + (extractReleaseList({ data: { releases: [{ id: "r2" }] } }) as { items: unknown[] }).items, + ).toEqual([{ id: "r2" }]); + }); + + it("release list extractor returns everything when no status filter is passed", () => { + const out = extractReleaseList( + { content: [{ id: "r1", status: "FAILED" }, { id: "r2", status: "Running" }] }, + {}, + ) as { items: unknown[] }; + expect(out.items).toHaveLength(2); + }); + + it("release list extractor matches an explicit status case-insensitively", () => { + const out = extractReleaseList( + { content: [{ id: "r1", status: "RUNNING" }, { id: "r2", status: "Failed" }], totalElements: 40 }, + { status: "Running" }, + ) as { items: Array<{ id: string }>; status_filter: string; total: number; _hint: string }; + expect(out.items.map((r) => r.id)).toEqual(["r1"]); + expect(out.status_filter).toBe("Running"); + expect(out.total).toBe(1); + expect(out._hint).toContain("client-side on this page only"); + expect(out._hint).toContain("2 unfiltered items"); + }); + + it("release_execution_phase list extractor maps phases to items", () => { + const out = extractPhases({ + release_id: "slug-1", + phases: [{ identifier: "deploy", status: "Running" }], + }) as { items: unknown[] }; + expect(out.items).toHaveLength(1); + }); + + it("release_execution_task preflight maps size to limit", async () => { + const input: Record = { release_id: "gfgfgffg-1.0.0-27d6a", size: 25 }; + await normalizeTaskPreflight({ client: {} as never, input, registry: {} as never }); + expect(input.limit).toBe(25); + }); + + it("release_execution_task list forwards cursor pagination metadata", () => { + const out = extractTasks( + { tasks: [{ identifier: "task-1" }], nextRequest: { cursor: "abc" }, last: false }, + { limit: 50 }, + ) as { pagination: { next_cursor?: string; last?: boolean } }; + expect(out.pagination.next_cursor).toBe("abc"); + expect(out.pagination.last).toBe(false); + }); + + it("release_execution_activity preflight builds sort array", async () => { + const input: Record = { + release_id: "gfgfgffg-1.0.0-27d6a", + status: "RUNNING, FAILED", + sort_field: "name", + sort_direction: "asc", + }; + await normalizeActivityPreflight({ client: {} as never, input, registry: {} as never }); + expect(input.sort).toEqual(["name", "asc"]); + expect(input.status).toEqual(["RUNNING", "FAILED"]); + }); + + it("phase input pathBuilder hits /input endpoint", () => { + const path = buildPhaseInputPath( + { + release_id: "rel-slug", + phase_identifier: "deploy", + }, + {}, + ); + expect(path).toBe("/api/orchestration/execution/release/rel-slug/phase/deploy/input"); + }); + + it("release_execution_phase_input get hits phase execution input endpoint", () => { + expect(phaseInputResource.operations.get?.path).toBe( + "/api/orchestration/execution/release/{releaseId}/phase/{phaseIdentifier}/input", + ); + expect(phaseInputResource.operations.get?.queryParams).toEqual({ phase_execution_id: "phaseExecutionId" }); + expect(phaseInputResource.identifierFields).toEqual(["release_id"]); + }); + + it("phase input extractor returns inputs array", () => { + const out = extractPhaseInput( + { inputs: [{ name: "env", value: "qa", type: "string" }] }, + { phase_identifier: "deploy" }, + ) as { inputs: unknown[]; total_inputs: number; phase_identifier: string }; + expect(out.inputs).toHaveLength(1); + expect(out.total_inputs).toBe(1); + expect(out.phase_identifier).toBe("deploy"); + }); + + it("phase output pathBuilder hits /output endpoint", () => { + const path = buildPhaseOutputPath( + { + release_id: "rel-slug", + phase_identifier: "deploy", + }, + {}, + ); + expect(path).toBe("/api/orchestration/execution/release/rel-slug/phase/deploy/output"); + }); + + it("activity output pathBuilder hits activity /output endpoint", () => { + const path = buildActivityOutputPath( + { + release_id: "rel-slug", + phase_identifier: "deploy", + activity_identifier: "run-pipeline", + }, + {}, + ); + expect(path).toBe( + "/api/orchestration/execution/release/rel-slug/phase/deploy/activity/run-pipeline/output", + ); + }); + + it("release_input get hits releaseInput endpoint", () => { + expect(releaseInputResource.operations.get?.path).toBe( + "/api/orchestration/execution/releaseInput/{releaseId}", + ); + }); + + it("release_execution_activity_input get hits activity execution input endpoint", () => { + expect(activityInputResource.operations.get?.path).toBe( + "/api/orchestration/execution/activity/{activityExecutionId}/input", + ); + expect(activityInputResource.identifierFields).toEqual(["activity_execution_id"]); + }); + + it("phase output extractor returns outputs array", () => { + const out = extractPhaseOutput( + { outputs: [{ name: "version", value: "1.0" }] }, + { phase_identifier: "deploy" }, + ) as { outputs: unknown[]; total_outputs: number }; + expect(out.outputs).toHaveLength(1); + expect(out.total_outputs).toBe(1); + }); + + it("release_input extractor returns yaml payload", () => { + const out = extractReleaseInput({ + release_id: "rel-1", + process_execution_id: "proc-1", + yaml: "inputs:\n env: qa", + }) as { yaml?: string }; + expect(out.yaml).toContain("env: qa"); + }); + + it("activity input extractor returns inputs array", () => { + const out = extractActivityInput({ inputs: [{ name: "env", value: "qa", type: "string" }] }) as { + inputs: unknown[]; + total_inputs: number; + }; + expect(out.inputs).toHaveLength(1); + expect(out.total_inputs).toBe(1); + }); + + it("activity output extractor includes activity_identifier", () => { + const out = extractActivityOutput( + { outputs: [] }, + { phase_identifier: "deploy", activity_identifier: "run-pipeline" }, + ) as { activity_identifier: string }; + expect(out.activity_identifier).toBe("run-pipeline"); + }); +}); + +describe("release-management extractors", () => { + it("releaseGetExtract flattens releaseInfo envelope", () => { + expect(releaseGetExtract({ releaseInfo: { id: "rel-1", status: "Running" } })).toEqual({ + release: { id: "rel-1", status: "Running" }, + }); + expect(releaseGetExtract({ id: "rel-2", status: "Failed" })).toEqual({ + id: "rel-2", + status: "Failed", + }); + }); + + it("rmgYamlEntityExtract projects stable YAML entity fields", () => { + expect( + rmgYamlEntityExtract({ + identifier: "deploy-proc", + name: "Deploy", + yaml: "process:\n identifier: deploy-proc", + orgIdentifier: "org1", + projectIdentifier: "proj1", + gitDetails: { branch: "main" }, + extraInternalField: "ignored", + }), + ).toEqual({ + identifier: "deploy-proc", + name: "Deploy", + yaml: "process:\n identifier: deploy-proc", + orgIdentifier: "org1", + projectIdentifier: "proj1", + git_details: { branch: "main" }, + }); + }); + + it("yamlWriteBody validates required body and yaml", () => { + expect(yamlWriteBody({ body: { yaml: "process:\n identifier: p1" } })).toEqual({ + yaml: "process:\n identifier: p1", + }); + expect( + yamlWriteBody({ + body: { yaml: "activity:\n identifier: a1", git_details: { branch: "main" } }, + }), + ).toEqual({ + yaml: "activity:\n identifier: a1", + git_details: { branch: "main" }, + }); + expect(() => yamlWriteBody({})).toThrow("body is required"); + expect(() => yamlWriteBody({ body: { yaml: "" } })).toThrow("body.yaml is required"); + expect(() => yamlWriteBody({ body: { yaml: 0 } })).toThrow("body.yaml is required"); + expect(() => yamlWriteBody({ body: null })).toThrow("body is required"); + }); +}); diff --git a/tests/tools/harness-schema-tool.test.ts b/tests/tools/harness-schema-tool.test.ts index fa1c5c8ca..596a2e1ef 100644 --- a/tests/tools/harness-schema-tool.test.ts +++ b/tests/tools/harness-schema-tool.test.ts @@ -71,7 +71,7 @@ describe("harness_schema live entities", () => { account: "acct-123", request: requestMock, } as unknown as HarnessClient; - registerSchemaTool(server, undefined, client); + registerSchemaTool(server, undefined, client, undefined); }); it("includes live entity types in registration", () => { @@ -242,10 +242,100 @@ describe("harness_schema live entities", () => { }); }); +const RMG_TEST_CONFIG = { + HARNESS_BASE_URL: "https://app.harness.io", + HARNESS_API_KEY: "pat.test.test.secret", + HARNESS_ACCOUNT_ID: "acct-123", +} as const; + +const PROCESS_LIVE_SCHEMA = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + required: ["process"], + properties: { + process: { + type: "object", + required: ["id", "name", "phases"], + properties: { + id: { type: "string" }, + name: { type: "string" }, + phases: { type: "array" }, + }, + }, + }, +}; + +describe("harness_schema RMG release definitions", () => { + let server: ReturnType; + let requestMock: ReturnType; + + beforeEach(() => { + server = makeMcpServer(); + requestMock = vi.fn().mockResolvedValue(PROCESS_LIVE_SCHEMA); + const client = { + account: "acct-123", + request: requestMock, + } as unknown as HarnessClient; + registerSchemaTool(server, undefined, client, RMG_TEST_CONFIG as never); + }); + + it("includes release_process and release_activity in registration", () => { + const call = server.registerTool.mock.calls.find((c: unknown[]) => c[0] === "harness_schema"); + const description = (call![1] as { description: string }).description; + expect(description).toContain("release_process"); + expect(description).toContain("release_activity"); + }); + + it("fetches release_process schema from RMG /api/yamlSchema", async () => { + const result = await server.call("harness_schema", { resource_type: "release_process" }); + const parsed = parseResult(result) as Record; + + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "GET", + path: "/api/yamlSchema", + baseUrl: "https://app.harness.io/gateway/rmg", + headerBasedScoping: true, + params: expect.objectContaining({ entityType: "PROCESS" }), + }), + ); + expect(parsed.source).toBe("rmg-yaml-schema"); + expect(parsed.yaml_root).toBe("process"); + expect(parsed.fields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "id", required: true }), + expect.objectContaining({ name: "phases", required: true }), + ]), + ); + }); + + it("fetches release_activity schema with entityType ACTIVITY", async () => { + await server.call("harness_schema", { resource_type: "release_activity" }); + + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ entityType: "ACTIVITY" }), + }), + ); + }); + + it("drills into RMG schema paths under the yaml root", async () => { + const result = await server.call("harness_schema", { + resource_type: "release_process", + path: "phases", + }); + const parsed = parseResult(result) as Record; + + expect(parsed.source).toBe("rmg-yaml-schema"); + expect(parsed.path).toBe("phases"); + expect((parsed.schema as Record).type).toBe("array"); + }); +}); + describe("harness_schema static enum", () => { it("lists both legacy and v1 bundled pipeline schemas (no version preference)", () => { const server = makeMcpServer(); - registerSchemaTool(server, undefined, undefined); + registerSchemaTool(server, undefined, undefined, undefined); const call = server.registerTool.mock.calls.find((c: unknown[]) => c[0] === "harness_schema"); const description = (call![1] as { description: string }).description; @@ -257,7 +347,7 @@ describe("harness_schema static enum", () => { it("does not expose v1 schemas removed from harness-schema upstream", () => { const server = makeMcpServer(); - registerSchemaTool(server, undefined, undefined); + registerSchemaTool(server, undefined, undefined, undefined); const call = server.registerTool.mock.calls.find((c: unknown[]) => c[0] === "harness_schema"); const description = (call![1] as { description: string }).description; @@ -298,7 +388,7 @@ const NESTED_STATIC_SCHEMA = { describe("harness_schema nested static definition lookup", () => { function makeServerWithNestedSchema() { const server = makeMcpServer(); - registerSchemaTool(server, undefined, undefined, { + registerSchemaTool(server, undefined, undefined, undefined, { nested_demo: { schema: NESTED_STATIC_SCHEMA, description: "Nested lookup test schema", @@ -363,7 +453,7 @@ describe("harness_schema nested static definition lookup", () => { it("resolves a bundled pipeline_v1 nested definition by bare name", async () => { const server = makeMcpServer(); - registerSchemaTool(server, undefined, undefined); + registerSchemaTool(server, undefined, undefined, undefined); const result = await server.call("harness_schema", { resource_type: "pipeline_v1", path: "EnvironmentV1", @@ -377,7 +467,7 @@ describe("harness_schema nested static definition lookup", () => { it("resolves newly synced K8sProgressiveCanaryRollback step definition from v0 pipeline", async () => { const server = makeMcpServer(); - registerSchemaTool(server, undefined, undefined); + registerSchemaTool(server, undefined, undefined, undefined); const result = await server.call("harness_schema", { resource_type: "pipeline", path: "K8sProgressiveCanaryRollbackStepNode", @@ -393,7 +483,7 @@ describe("harness_schema nested static definition lookup", () => { it("resolves newly synced DeployGoogleAgentRuntimeRevision step definition from v0 pipeline", async () => { const server = makeMcpServer(); - registerSchemaTool(server, undefined, undefined); + registerSchemaTool(server, undefined, undefined, undefined); const result = await server.call("harness_schema", { resource_type: "pipeline", path: "DeployGoogleAgentRuntimeRevisionStepNode", @@ -409,7 +499,7 @@ describe("harness_schema nested static definition lookup", () => { it("resolves v1 Clone definition with upstream user field", async () => { const server = makeMcpServer(); - registerSchemaTool(server, undefined, undefined); + registerSchemaTool(server, undefined, undefined, undefined); const result = await server.call("harness_schema", { resource_type: "pipeline_v1", path: "Clone", @@ -423,7 +513,7 @@ describe("harness_schema nested static definition lookup", () => { it("resolves newly synced DeployAwsAgentCoreRevision step definition from v0 pipeline", async () => { const server = makeMcpServer(); - registerSchemaTool(server, undefined, undefined); + registerSchemaTool(server, undefined, undefined, undefined); const result = await server.call("harness_schema", { resource_type: "pipeline", path: "DeployAwsAgentCoreRevisionStepNode", @@ -439,7 +529,7 @@ describe("harness_schema nested static definition lookup", () => { it("resolves IdentitiesConfig from v0 pipeline common definitions", async () => { const server = makeMcpServer(); - registerSchemaTool(server, undefined, undefined); + registerSchemaTool(server, undefined, undefined, undefined); const result = await server.call("harness_schema", { resource_type: "pipeline", path: "IdentitiesConfig", @@ -455,7 +545,7 @@ describe("harness_schema nested static definition lookup", () => { it("resolves DynamicStageNodeV1 from v1 pipeline unified stages", async () => { const server = makeMcpServer(); - registerSchemaTool(server, undefined, undefined); + registerSchemaTool(server, undefined, undefined, undefined); const result = await server.call("harness_schema", { resource_type: "pipeline_v1", path: "DynamicStageNodeV1", @@ -514,7 +604,7 @@ const WRAPPER_VS_SCHEMA_SCHEMA = { describe("harness_schema wrapper definition fallback", () => { it("resolves a bare wrapper definition when it is not a schema node", async () => { const server = makeMcpServer(); - registerSchemaTool(server, undefined, undefined, { + registerSchemaTool(server, undefined, undefined, undefined, { wrapper_demo: { schema: WRAPPER_FALLBACK_SCHEMA, description: "Wrapper fallback test schema", @@ -538,7 +628,7 @@ describe("harness_schema wrapper definition fallback", () => { it("prefers a nested schema-node match over a shallow wrapper with the same name", async () => { const server = makeMcpServer(); - registerSchemaTool(server, undefined, undefined, { + registerSchemaTool(server, undefined, undefined, undefined, { prefer_schema: { schema: WRAPPER_VS_SCHEMA_SCHEMA, description: "Wrapper vs schema node preference test", diff --git a/tests/tools/zod-input-schema-descriptions.test.ts b/tests/tools/zod-input-schema-descriptions.test.ts index e90e5c003..444b40b15 100644 --- a/tests/tools/zod-input-schema-descriptions.test.ts +++ b/tests/tools/zod-input-schema-descriptions.test.ts @@ -150,7 +150,7 @@ describe("Zod 4 input schema descriptions (PR #398 regression)", () => { it("harness_schema exposes descriptions on live-entity scope fields", async () => { const { registerSchemaTool } = await import("../../src/tools/harness-schema.js"); - registerSchemaTool(server as never, registry, client); + registerSchemaTool(server as never, registry, client, undefined); expectFieldDescriptions(server.schema("harness_schema").inputSchema, [ "resource_type", diff --git a/tests/utils/url-parser.test.ts b/tests/utils/url-parser.test.ts index d4c065d02..39024ba4f 100644 --- a/tests/utils/url-parser.test.ts +++ b/tests/utils/url-parser.test.ts @@ -499,6 +499,30 @@ describe("applyUrlDefaults", () => { expect(result.project_id).toBeUndefined(); }); + it("parses RMG release phases URL into release_execution_phase + release_id slug", () => { + const result = parseHarnessUrl( + "https://qa.harness.io/ng/account/OgiB4-xETamKNVAz-wQRjw/module/rmg/orgs/Ng_Pipelines_K8s_Organisations/projects/DoNotDelete_Brooke2/release-management/releases/gfgfgffg-1.0.0-27d6a/execution/phases", + ); + expect(result.account_id).toBe("OgiB4-xETamKNVAz-wQRjw"); + expect(result.org_id).toBe("Ng_Pipelines_K8s_Organisations"); + expect(result.project_id).toBe("DoNotDelete_Brooke2"); + expect(result.resource_scope).toBe("project"); + expect(result.resource_type).toBe("release_execution_phase"); + expect(result.release_id).toBe("gfgfgffg-1.0.0-27d6a"); + expect(result.resource_id).toBe("gfgfgffg-1.0.0-27d6a"); + }); + + it("applyUrlDefaults merges RMG release_id for harness_list", () => { + const result = applyUrlDefaults( + {}, + "https://qa.harness.io/ng/account/acc/module/rmg/orgs/myOrg/projects/myProj/release-management/releases/myrel-1.0.0-abc/execution/phases", + ); + expect(result.resource_type).toBe("release_execution_phase"); + expect(result.release_id).toBe("myrel-1.0.0-abc"); + expect(result.org_id).toBe("myOrg"); + expect(result.project_id).toBe("myProj"); + }); + it("does not mutate the original args object", () => { const args = { resource_type: "pipeline" }; const result = applyUrlDefaults(