diff --git a/README.md b/README.md index ddf143fe9..0337b3cc2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## Harness MCP Server 2.0 -An MCP (Model Context Protocol) server that gives AI agents full access to the Harness.io platform through 11 consolidated tools and 168 resource types. +An MCP (Model Context Protocol) server that gives AI agents full access to the Harness.io platform through 11 consolidated tools and 169 resource types. ## Why Use This MCP Server @@ -8,7 +8,7 @@ Most MCP servers map one tool per API endpoint. For a platform as broad as Harne This server is built differently: -- **11 tools, 168 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. +- **11 tools, 169 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.** 31 toolsets spanning CI/CD, GitOps, Feature Flags, Cloud Cost Management, Security Testing, Chaos Engineering, Database DevOps, Internal Developer Portal, Software Supply Chain, Governance, Service Overrides, Visualizations, and more. Not just pipelines — the entire Harness platform. - **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. - **30 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. @@ -954,7 +954,7 @@ Harness pipelines can be stored in three ways: ## Resource Types -168 resource types organized across 31 toolsets. Each resource type supports a subset of CRUD operations and optional execute actions. +169 resource types organized across 31 toolsets. Each resource type supports a subset of CRUD operations and optional execute actions. ### Platform @@ -1491,7 +1491,7 @@ Available toolset names: +--------v---------+ | Registry | <-- Declarative resource definitions | 32 Toolsets | (data files, not code) - | 168 Resource Types| + | 169 Resource Types| +--------+---------+ | +--------v---------+ diff --git a/docs/testing/idp_score/test_plan.md b/docs/testing/idp_score/test_plan.md index 8554514e9..717b57bef 100644 --- a/docs/testing/idp_score/test_plan.md +++ b/docs/testing/idp_score/test_plan.md @@ -1,34 +1,33 @@ -# Test Plan: IDP Score (`idp_score`) +# Test Plan: IDP Score (`idp_score`, `idp_score_summary`) | Field | Value | |-------|-------| -| **Resource Type** | `idp_score` | -| **Display Name** | IDP Score | +| **Resource Type** | `idp_score`, `idp_score_summary` | +| **Display Name** | IDP Score, IDP Score Summary | | **Toolset** | idp | | **Scope** | account | -| **Operations** | list, get | +| **Operations** | `idp_score`: list; `idp_score_summary`: get | | **Execute Actions** | None | -| **Identifier Fields** | entity_id | -| **Filter Fields** | None | +| **Identifier Fields** | entity_identifier | +| **Filter Fields** | entity_identifier | | **Deep Link** | No | ## Test Cases | Test ID | Category | Description | Prompt | Expected Result | |---------|----------|-------------|--------|-----------------| -| TC-idp_score-001 | List | List all entity scores with defaults | `harness_list(resource_type="idp_score")` | Returns paginated list of entity scores | -| TC-idp_score-002 | List | List with pagination page 0 | `harness_list(resource_type="idp_score", page=0, size=5)` | Returns first page with up to 5 scores | -| TC-idp_score-003 | List | List with pagination page 1 | `harness_list(resource_type="idp_score", page=1, size=5)` | Returns second page of scores | -| TC-idp_score-004 | List | List with large page size | `harness_list(resource_type="idp_score", size=100)` | Returns up to 100 scores | -| TC-idp_score-005 | Get | Get score by entity_id | `harness_get(resource_type="idp_score", entity_id="my-service")` | Returns score summary for the entity | -| TC-idp_score-006 | Get | Verify score response structure | `harness_get(resource_type="idp_score", entity_id="my-service")` | Response contains score summary fields | -| TC-idp_score-007 | Error | Get with missing entity_id | `harness_get(resource_type="idp_score")` | Error: entity_id is required | -| TC-idp_score-008 | Error | Get non-existent entity score | `harness_get(resource_type="idp_score", entity_id="nonexistent")` | Error: entity not found (404) | -| TC-idp_score-009 | Edge | List with page beyond data | `harness_list(resource_type="idp_score", page=9999)` | Returns empty list | -| TC-idp_score-010 | Edge | List with size=1 | `harness_list(resource_type="idp_score", size=1)` | Returns exactly 1 score | +| TC-idp_score-001 | List | List scorecard scores for an entity | `harness_list(resource_type="idp_score", filters={"entity_identifier":"default/Component/my-service"})` | Returns overall_score plus scorecard score items for the entity | +| TC-idp_score-002 | List | List scorecard scores using top-level entity_identifier | `harness_list(resource_type="idp_score", entity_identifier="default/Component/my-service")` | Returns overall_score plus scorecard score items for the entity | +| TC-idp_score-003 | Error | List without entity_identifier | `harness_list(resource_type="idp_score")` | Error from IDP API or validation indicating entity_identifier is required | +| TC-idp_score-004 | Error | List scorecard scores for non-existent entity | `harness_list(resource_type="idp_score", filters={"entity_identifier":"default/Component/nonexistent"})` | Error: entity not found or empty/no scores depending on API behavior | +| TC-idp_score_summary-001 | Get | Get aggregate score summary for an entity | `harness_get(resource_type="idp_score_summary", resource_id="default/Component/my-service")` | Returns aggregate score summary for the entity | +| TC-idp_score_summary-002 | Get | Get aggregate score summary via params | `harness_get(resource_type="idp_score_summary", params={"entity_identifier":"default/Component/my-service"})` | Returns aggregate score summary for the entity | +| TC-idp_score_summary-003 | Error | Get summary without entity_identifier | `harness_get(resource_type="idp_score_summary")` | Error: entity_identifier is required | +| TC-idp_score_summary-004 | Error | Get summary for non-existent entity | `harness_get(resource_type="idp_score_summary", resource_id="default/Component/nonexistent")` | Error: entity not found or empty/no summary depending on API behavior | ## Notes - Account-scoped resource -- API paths: GET `/v1/scores` (list), GET `/v1/scores/{entityId}` (get) -- No filter fields; only pagination params supported for list +- API paths: GET `/v1/scores` (`idp_score` list), GET `/v1/scores/summary` (`idp_score_summary` get) +- `idp_score` requires `entity_identifier` and does not expose pagination +- `idp_score_summary` requires `entity_identifier` - No deep link template defined diff --git a/docs/testing/idp_score/test_report.md b/docs/testing/idp_score/test_report.md index d8df65d8c..c83cda59f 100644 --- a/docs/testing/idp_score/test_report.md +++ b/docs/testing/idp_score/test_report.md @@ -1,8 +1,8 @@ -# Test Report: IDP Score (`idp_score`) +# Test Report: IDP Score (`idp_score`, `idp_score_summary`) | Field | Value | |-------|-------| -| **Resource Type** | `idp_score` | +| **Resource Type** | `idp_score`, `idp_score_summary` | | **Date** | 2026-03-23 | | **Tester** | MCP Automated Test | | **Account ID** | px7xd_BFRCi-pfWPYXVjvw | @@ -13,26 +13,24 @@ | Test ID | Description | Prompt | Expected Result | Status | Actual Result | Notes | |---------|-------------|--------|-----------------|--------|---------------|-------| -| TC-idp_score-001 | List all entity scores with defaults | `harness_list(resource_type="idp_score")` | Returns paginated list of entity scores | ✅ Passed | Returns entity scores (requires entity_identifier filter) | Requires entity_identifier filter | -| TC-idp_score-002 | List with pagination page 0 | `harness_list(resource_type="idp_score", page=0, size=5)` | Returns first page with up to 5 scores | ⬜ Pending | | | -| TC-idp_score-003 | List with pagination page 1 | `harness_list(resource_type="idp_score", page=1, size=5)` | Returns second page of scores | ⬜ Pending | | | -| TC-idp_score-004 | List with large page size | `harness_list(resource_type="idp_score", size=100)` | Returns up to 100 scores | ⬜ Pending | | | -| TC-idp_score-005 | Get score by entity_id | `harness_get(resource_type="idp_score", entity_id="my-service")` | Returns score summary for the entity | ⬜ Pending | | | -| TC-idp_score-006 | Verify score response structure | `harness_get(resource_type="idp_score", entity_id="my-service")` | Response contains score summary fields | ⬜ Pending | | | -| TC-idp_score-007 | Get with missing entity_id | `harness_get(resource_type="idp_score")` | Error: entity_id is required | ⬜ Pending | | | -| TC-idp_score-008 | Get non-existent entity score | `harness_get(resource_type="idp_score", entity_id="nonexistent")` | Error: entity not found (404) | ⬜ Pending | | | -| TC-idp_score-009 | List with page beyond data | `harness_list(resource_type="idp_score", page=9999)` | Returns empty list | ⬜ Pending | | | -| TC-idp_score-010 | List with size=1 | `harness_list(resource_type="idp_score", size=1)` | Returns exactly 1 score | ⬜ Pending | | | +| TC-idp_score-001 | List scorecard scores for an entity | `harness_list(resource_type="idp_score", filters={"entity_identifier":"default/Component/my-service"})` | Returns overall_score plus scorecard score items for the entity | ⬜ Pending | | | +| TC-idp_score-002 | List scorecard scores using top-level entity_identifier | `harness_list(resource_type="idp_score", entity_identifier="default/Component/my-service")` | Returns overall_score plus scorecard score items for the entity | ⬜ Pending | | | +| TC-idp_score-003 | List without entity_identifier | `harness_list(resource_type="idp_score")` | Error from IDP API or validation indicating entity_identifier is required | ⬜ Pending | | | +| TC-idp_score-004 | List scorecard scores for non-existent entity | `harness_list(resource_type="idp_score", filters={"entity_identifier":"default/Component/nonexistent"})` | Error: entity not found or empty/no scores depending on API behavior | ⬜ Pending | | | +| TC-idp_score_summary-001 | Get aggregate score summary for an entity | `harness_get(resource_type="idp_score_summary", resource_id="default/Component/my-service")` | Returns aggregate score summary for the entity | ⬜ Pending | | | +| TC-idp_score_summary-002 | Get aggregate score summary via params | `harness_get(resource_type="idp_score_summary", params={"entity_identifier":"default/Component/my-service"})` | Returns aggregate score summary for the entity | ⬜ Pending | | | +| TC-idp_score_summary-003 | Get summary without entity_identifier | `harness_get(resource_type="idp_score_summary")` | Error: entity_identifier is required | ⬜ Pending | | | +| TC-idp_score_summary-004 | Get summary for non-existent entity | `harness_get(resource_type="idp_score_summary", resource_id="default/Component/nonexistent")` | Error: entity not found or empty/no summary depending on API behavior | ⬜ Pending | | | ## Summary | Metric | Count | |--------|-------| -| Total Tests | 10 | -| ✅ Passed | 1 | +| Total Tests | 8 | +| ✅ Passed | 0 | | ❌ Failed | 0 | | ⚠️ Blocked | 0 | -| ⬜ Not Run | 9 | +| ⬜ Not Run | 8 | ## Issues Found diff --git a/src/registry/toolsets/idp.ts b/src/registry/toolsets/idp.ts index 35327f65c..5fea61017 100644 --- a/src/registry/toolsets/idp.ts +++ b/src/registry/toolsets/idp.ts @@ -1,5 +1,51 @@ -import type { ToolsetDefinition } from "../types.js"; -import { ngExtract, pageExtract, v1ListExtract } from "../extractors.js"; +import type { PathBuilderConfig, ToolsetDefinition } from "../types.js"; +import { ngExtract, passthrough, v1ListExtract } from "../extractors.js"; +import { parse as parseYaml } from "yaml"; + +const CONFIG_API_KEY = "__config_api_key"; +const PARAM_REF_RE = /^\s*\$\{\{\s*parameters\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}\s*$/; + +const extractAuthParamRefs = (yamlStr: string): { apikeyRefs: string[]; apiKeySecretRefs: string[] } => { + let parsed: unknown; + try { + parsed = parseYaml(yamlStr); + } catch (err) { + throw new Error(`Failed to parse workflow_details.yaml: ${err instanceof Error ? err.message : String(err)}`); + } + + const steps = (parsed as { spec?: { steps?: unknown[] } } | undefined)?.spec?.steps; + const apikeyRefs: string[] = []; + const apiKeySecretRefs: string[] = []; + if (!Array.isArray(steps)) return { apikeyRefs, apiKeySecretRefs }; + + for (const step of steps) { + const input = (step as { input?: Record } | undefined)?.input; + if (!input || typeof input !== "object") continue; + + const apiKey = input.apikey; + if (typeof apiKey === "string") { + const match = PARAM_REF_RE.exec(apiKey); + if (match?.[1]) apikeyRefs.push(match[1]); + } + + const apiKeySecret = input.apiKeySecret; + if (typeof apiKeySecret === "string") { + const match = PARAM_REF_RE.exec(apiKeySecret); + if (match?.[1]) apiKeySecretRefs.push(match[1]); + } + } + + return { apikeyRefs, apiKeySecretRefs }; +}; + +const scorecardStatsExtract = (raw: unknown): unknown => { + const r = raw as { name?: string; stats?: unknown[]; timestamp?: number | null }; + return { + name: r.name, + stats: r.stats ?? [], + time: r.timestamp != null ? new Date(r.timestamp).toISOString() : "", + }; +}; export const idpToolset: ToolsetDefinition = { name: "idp", @@ -9,14 +55,21 @@ export const idpToolset: ToolsetDefinition = { { resourceType: "idp_entity", displayName: "IDP Entity", - description: "Internal Developer Portal catalog entity. Supports list and get.", + description: "Internal Developer Portal catalog entity. Supports list and get. Lists Harness IDP catalog metadata (services, APIs, user groups, resources, etc.) including identifier, scope, kind, ref type (INLINE/GIT), YAML, Git details, ownership, tags, lifecycle, scorecards, status, and group.", toolset: "idp", scope: "account", - identifierFields: ["entity_id", "kind"], + identifierFields: ["kind", "entity_id"], listFilterFields: [ - { name: "kind", description: "Catalog entity kind filter", enum: ["api", "component", "environment", "environmentblueprint", "group", "resource", "user", "workflow"] }, - { name: "search", description: "Search catalog entities by name or keyword" }, - { name: "namespace", description: "Entity namespace (defaults to 'account' for account scope)" }, + { name: "kind", description: "Comma-separated list of entity kinds to fetch. Defaults to 'component,api,resource'.", enum: ["api", "component", "environment", "environmentblueprint", "group", "resource", "user", "workflow"] }, + { name: "search_term", description: "Filter entities by name or keyword" }, + { name: "scope_level", description: "Scope level for the entities query. 'default' uses the configured org/project; 'account', 'org', and 'project' force that scope explicitly.", enum: ["default", "account", "org", "project"] }, + { name: "sort", description: "Sort entities (e.g. 'name,ASC')" }, + { name: "owned_by_me", description: "Only return entities owned by the current user", type: "boolean" }, + { name: "favorites", description: "Only return entities the current user has favorited", type: "boolean" }, + { name: "type", description: "Comma-separated list of entity types to filter on" }, + { name: "owner", description: "Comma-separated list of owner references to filter on" }, + { name: "lifecycle", description: "Comma-separated list of lifecycles to filter on (e.g. 'experimental,production')" }, + { name: "tags", description: "Comma-separated list of tags to filter on" }, ], deepLinkTemplate: "/ng/account/{accountId}/idp/catalog", operations: { @@ -24,20 +77,65 @@ export const idpToolset: ToolsetDefinition = { method: "GET", path: "/v1/entities", operationPolicy: { risk: "read", retryPolicy: "safe" }, + pathBuilder: (input, config) => { + const scopeLevel = String(input.scope_level ?? "default").toLowerCase(); + const orgId = (input.org_id as string) || config.HARNESS_ORG || ""; + const projectId = (input.project_id as string) || config.HARNESS_PROJECT || ""; + + let scopes: string; + switch (scopeLevel) { + case "account": + scopes = "account"; + break; + case "org": + scopes = orgId ? `account.${orgId}` : "account.org"; + break; + case "project": + if (orgId && projectId) scopes = `account.${orgId}.${projectId}`; + else if (orgId) scopes = `account.${orgId}.project`; + else scopes = "account.org.project"; + break; + default: + if (orgId && projectId) scopes = `account.${orgId}.${projectId}`; + else if (orgId) scopes = `account.${orgId}.*`; + else scopes = "account.*"; + } + input.scopes = scopes; + + if (orgId) input.org_id = orgId; + if (projectId) input.project_id = projectId; + + return "/v1/entities"; + }, queryParams: { - kind: "kind", - search: "search_term", page: "page", size: "limit", - scope_level: "scope_level", + search_term: "search_term", + sort: "sort", + owned_by_me: "owned_by_me", + favorites: "favorites", + kind: "kind", + type: "type", + owner: "owner", + lifecycle: "lifecycle", + tags: "tags", + scopes: "scopes", + org_id: "orgIdentifier", + project_id: "projectIdentifier", + }, + defaultQueryParams: { + page: "0", + limit: "20", + kind: "component,api,resource", + owned_by_me: "false", + favorites: "false", }, - defaultQueryParams: { scope_level: "ACCOUNT" }, responseExtractor: v1ListExtract(), - description: "List IDP catalog entities", + description: "List IDP catalog entities. Defaults: page=0, limit=20 (max 100). If 'limit' is not supplied, paginate by calling repeatedly. Note: workflow entities may include a 'token' field — IGNORE it.", }, get: { method: "GET", - path: "/v1/entities/{scope}/{kind}/{namespace}/{entityId}", + path: "/v1/entities/{scope}/{kind}/{entityId}", pathBuilder: (input) => { let scope = "account"; const orgId = input.org_id as string | undefined; @@ -48,14 +146,28 @@ export const idpToolset: ToolsetDefinition = { scope += `.${projectId}`; } } - const kind = (input.kind as string) || "component"; - const namespace = (input.namespace as string) || scope; - const entityId = input.entity_id as string; - return `/v1/entities/${encodeURIComponent(scope)}/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(entityId)}`; + + const kind = input.kind as string | undefined; + const entityId = input.entity_id as string | undefined; + if (!kind) { + throw new Error(`Missing required field "kind" for idp_entity. Pass it via params: { kind: "component" }.`); + } + if (!entityId) { + throw new Error(`Missing required field "entity_id" for idp_entity. Pass it via params or as resource_id.`); + } + + if (orgId) input.org_id = orgId; + if (projectId) input.project_id = projectId; + + return `/v1/entities/${encodeURIComponent(scope)}/${encodeURIComponent(kind)}/${encodeURIComponent(entityId)}`; + }, + queryParams: { + org_id: "orgIdentifier", + project_id: "projectIdentifier", }, operationPolicy: { risk: "read", retryPolicy: "safe" }, - responseExtractor: ngExtract, - description: "Get IDP catalog entity details by scope, kind, namespace, and name (entity_ref format: kind:namespace/name)", + responseExtractor: passthrough, + description: "Get details of a specific IDP catalog entity by kind + entity_id. Returns the entity's identifier, scope, kind, ref type (INLINE/GIT), YAML, Git details, ownership, tags, lifecycle, scorecards, status, and group. Use list_entities first to discover the entity_id. Note: workflow entities may include a 'token' field — IGNORE it.", }, }, }, @@ -77,36 +189,54 @@ export const idpToolset: ToolsetDefinition = { size: "limit", }, responseExtractor: v1ListExtract("scorecard"), - description: "List IDP scorecards", + description: "List scorecards in the Harness IDP Catalog.", }, get: { method: "GET", path: "/v1/scorecards/{scorecardIdentifier}", operationPolicy: { risk: "read", retryPolicy: "safe" }, pathParams: { scorecard_id: "scorecardIdentifier" }, - responseExtractor: ngExtract, - description: "Get scorecard details", + responseExtractor: passthrough, + description: "Get details of a specific scorecard in the Harness IDP Catalog. Use this only when the scorecard_id is known (use list_scorecards first to discover it).", }, }, }, { resourceType: "scorecard_check", displayName: "Scorecard Check", - description: "Individual check within an IDP scorecard. Supports list and get.", + description: "Individual check within an IDP scorecard. A check is a query performed against a data point for a software component which results in either Pass or Fail. Supports list and get.", toolset: "idp", scope: "account", identifierFields: ["check_id"], + listFilterFields: [ + { name: "search_term", description: "Filter checks by name or keyword" }, + { name: "sort_type", description: "Field to sort checks by", enum: ["name", "description", "data_source"] }, + { name: "sort_order", description: "Sort direction", enum: ["ASC", "DESC"] }, + { name: "is_custom", description: "(get only) Whether the check is a custom check. Set to true for custom checks; defaults to false.", type: "boolean" }, + ], operations: { list: { method: "GET", path: "/v1/checks", operationPolicy: { risk: "read", retryPolicy: "safe" }, + pathBuilder: (input) => { + // Mirror the Go tool's behavior: combine sort_type + sort_order into a + // single `sort` query value (e.g. "name,ASC"). Only set when not + // already provided by the caller. + if (!input.sort && input.sort_type) { + const order = input.sort_order ? `,${String(input.sort_order)}` : ""; + input.sort = `${String(input.sort_type)}${order}`; + } + return "/v1/checks"; + }, queryParams: { page: "page", size: "limit", + search_term: "search_term", + sort: "sort", }, responseExtractor: v1ListExtract("check"), - description: "List scorecard checks", + description: "List scorecard checks in the Harness IDP Catalog.", }, get: { method: "GET", @@ -114,15 +244,16 @@ export const idpToolset: ToolsetDefinition = { operationPolicy: { risk: "read", retryPolicy: "safe" }, pathParams: { check_id: "checkIdentifier" }, queryParams: { is_custom: "custom" }, - responseExtractor: ngExtract, - description: "Get scorecard check details", + defaultQueryParams: { custom: "false" }, + responseExtractor: passthrough, + description: "Get details of a specific scorecard check. Pass is_custom=true for custom checks (the scorecard details indicate this).", }, }, }, { resourceType: "scorecard_stats", displayName: "Scorecard Stats", - description: "Aggregate statistics for an IDP scorecard. Supports get.", + description: "Aggregate statistics for an IDP scorecard — the scores of every entity that has this scorecard configured. Supports get.", toolset: "idp", scope: "account", identifierFields: ["scorecard_id"], @@ -133,15 +264,15 @@ export const idpToolset: ToolsetDefinition = { path: "/v1/scorecards/{scorecardIdentifier}/stats", operationPolicy: { risk: "read", retryPolicy: "safe" }, pathParams: { scorecard_id: "scorecardIdentifier" }, - responseExtractor: ngExtract, - description: "Get aggregate statistics for a scorecard", + responseExtractor: scorecardStatsExtract, + description: "Get aggregate stats for a scorecard — the scores of every entity that has this scorecard configured. The raw 'timestamp' field is converted to RFC3339 'time'.", }, }, }, { resourceType: "scorecard_check_stats", displayName: "Scorecard Check Stats", - description: "Statistics for a specific scorecard check. Supports get.", + description: "Statistics for a specific scorecard check — the PASS/FAIL status for every entity whose scorecard contains this check. Supports get.", toolset: "idp", scope: "account", identifierFields: ["check_id"], @@ -154,20 +285,20 @@ export const idpToolset: ToolsetDefinition = { pathParams: { check_id: "checkIdentifier" }, queryParams: { is_custom: "custom" }, defaultQueryParams: { custom: "false" }, - responseExtractor: ngExtract, - description: "Get statistics for a specific scorecard check. Pass is_custom=true for custom checks.", + responseExtractor: scorecardStatsExtract, + description: "Get stats for a scorecard check — the PASS/FAIL status for every entity whose scorecard contains this check. Pass is_custom=true for custom checks. The raw 'timestamp' field is converted to RFC3339 'time'.", }, }, }, { resourceType: "idp_score", displayName: "IDP Score", - description: "Entity score summary from IDP scorecards. Supports list and get. List requires entity_identifier filter.", + description: "Per-scorecard scores for an entity in the Harness IDP Catalog. Supports list (returns all scorecard scores for the given entity).", toolset: "idp", scope: "account", - identifierFields: ["entity_id"], + identifierFields: ["entity_identifier"], listFilterFields: [ - { name: "entity_identifier", description: "Entity identifier (required for listing scores)" }, + { name: "entity_identifier", description: "Entity identifier in 'namespace/Kind/name' format (e.g. 'default/Component/my-service'). Required.", required: true }, ], operations: { list: { @@ -175,8 +306,6 @@ export const idpToolset: ToolsetDefinition = { path: "/v1/scores", operationPolicy: { risk: "read", retryPolicy: "safe" }, queryParams: { - page: "page", - size: "limit", entity_identifier: "entity_identifier", }, responseExtractor: (raw: unknown) => { @@ -184,53 +313,234 @@ export const idpToolset: ToolsetDefinition = { const items = r.scorecard_scores ?? []; return { overall_score: r.overall_score, items, total: items.length }; }, - description: "List entity scores. Requires entity_identifier filter (format: namespace/Kind/name, e.g. default/Component/my-service).", + description: "Get scores for every scorecard configured against an entity. Required filter: entity_identifier (format 'namespace/Kind/name', e.g. 'default/Component/my-service').", }, + }, + }, + { + resourceType: "idp_score_summary", + displayName: "IDP Score Summary", + description: "Aggregate score summary across all scorecards for an entity. Supports get.", + toolset: "idp", + scope: "account", + identifierFields: ["entity_identifier"], + operations: { get: { method: "GET", - path: "/v1/scores/{entityId}", + path: "/v1/scores/summary", operationPolicy: { risk: "read", retryPolicy: "safe" }, - pathParams: { entity_id: "entityId" }, - responseExtractor: ngExtract, - description: "Get score summary for an entity", + pathBuilder: (input) => { + if (!input.entity_identifier) { + throw new Error( + "Missing required field 'entity_identifier' for idp_score_summary. " + + "Pass it via params or as resource_id (format: 'namespace/Kind/name', e.g. 'default/Component/my-service').", + ); + } + return "/v1/scores/summary"; + }, + queryParams: { + entity_identifier: "entity_identifier", + }, + responseExtractor: passthrough, + description: "Get aggregate score summary across all scorecards for an entity. Required: entity_identifier (format 'namespace/Kind/name', e.g. 'default/Component/my-service').", }, }, }, { resourceType: "idp_workflow", displayName: "IDP Workflow", - description: "IDP self-service workflow. Supports list and execute action.", + description: + "IDP self-service workflow. Supports list and execute. " + + "Workflows are IDP catalog entities with kind=workflow — list here is a thin wrapper over /v1/entities that pins kind=workflow and exposes the same filter surface as idp_entity (search_term, scope_level, owned_by_me, favorites, owner, lifecycle, tags, sort).", toolset: "idp", scope: "account", identifierFields: ["workflow_id"], listFilterFields: [ - { name: "scope_level", description: "Scope level filter (ACCOUNT, ORG, PROJECT, ALL)", enum: ["ACCOUNT", "ORG", "PROJECT", "ALL"] }, + { name: "search_term", description: "Filter workflows by name or keyword" }, + { name: "scope_level", description: "Scope level for the workflow query. 'default' uses the configured org/project; 'account', 'org', and 'project' force that scope explicitly.", enum: ["default", "account", "org", "project"] }, + { name: "sort", description: "Sort workflows (e.g. 'name,ASC')" }, + { name: "owned_by_me", description: "Only return workflows owned by the current user", type: "boolean" }, + { name: "favorites", description: "Only return workflows the current user has favorited", type: "boolean" }, + { name: "owner", description: "Comma-separated list of owner references to filter on" }, + { name: "lifecycle", description: "Comma-separated list of lifecycles to filter on (e.g. 'experimental,production')" }, + { name: "tags", description: "Comma-separated list of tags to filter on" }, ], operations: { list: { method: "GET", path: "/v1/entities", operationPolicy: { risk: "read", retryPolicy: "safe" }, + pathBuilder: (input, config) => { + const scopeLevel = String(input.scope_level ?? "default").toLowerCase(); + const orgId = (input.org_id as string) || config.HARNESS_ORG || ""; + const projectId = (input.project_id as string) || config.HARNESS_PROJECT || ""; + + let scopes: string; + switch (scopeLevel) { + case "account": + scopes = "account"; + break; + case "org": + scopes = orgId ? `account.${orgId}` : "account.org"; + break; + case "project": + if (orgId && projectId) scopes = `account.${orgId}.${projectId}`; + else if (orgId) scopes = `account.${orgId}.project`; + else scopes = "account.org.project"; + break; + default: + if (orgId && projectId) scopes = `account.${orgId}.${projectId}`; + else if (orgId) scopes = `account.${orgId}.*`; + else scopes = "account.*"; + } + input.scopes = scopes; + + if (orgId) input.org_id = orgId; + if (projectId) input.project_id = projectId; + + return "/v1/entities"; + }, queryParams: { - scope_level: "scope_level", + page: "page", + size: "limit", + search_term: "search_term", + sort: "sort", + owned_by_me: "owned_by_me", + favorites: "favorites", + owner: "owner", + lifecycle: "lifecycle", + tags: "tags", + scopes: "scopes", + org_id: "orgIdentifier", + project_id: "projectIdentifier", + }, + defaultQueryParams: { + page: "0", + limit: "20", + kind: "workflow", + owned_by_me: "false", + favorites: "false", }, - defaultQueryParams: { kind: "workflow", scope_level: "ACCOUNT" }, responseExtractor: v1ListExtract(), - description: "List IDP workflows", + description: "List IDP self-service workflows. Pins kind=workflow on the underlying /v1/entities call. Defaults: page=0, limit=20 (max 100). If 'limit' is not supplied, paginate by calling repeatedly. Workflow entities may include a 'token' field — IGNORE it.", }, }, executeActions: { execute: { method: "POST", - path: "/v1/scaffolder/tasks", + path: "/v2/workflows/execute", operationPolicy: { risk: "high_write", retryPolicy: "do_not_retry" }, - bodyBuilder: (input) => input.body ?? {}, + pathBuilder: (input, config) => { + const cfg = config as PathBuilderConfig & { HARNESS_API_KEY?: string }; + input[CONFIG_API_KEY] = cfg.HARNESS_API_KEY ?? ""; + return "/v2/workflows/execute"; + }, + queryParams: { + org_id: "orgIdentifier", + project_id: "projectIdentifier", + }, + bodyBuilder: (input) => { + const b = (input.body as Record | undefined) ?? {}; + const wfDetails = b.workflow_details as Record | undefined; + if (!wfDetails) { + throw new Error( + "workflow_details is required. Fetch it first with harness_get(resource_type=idp_entity, kind=workflow, entity_id=) and pass the result.", + ); + } + + const yamlStr = wfDetails.yaml; + if (typeof yamlStr !== "string") { + throw new Error("workflow_details.yaml is missing or not a string."); + } + + const identifier = + (b.identifier as string | undefined) ?? + (wfDetails.identifier as string | undefined) ?? + (input.workflow_id as string | undefined); + if (!identifier) { + throw new Error( + "missing required parameter: workflow identifier (pass via body.identifier or resource_id).", + ); + } + + const refs = extractAuthParamRefs(yamlStr); + const values = { ...((b.values as Record | undefined) ?? {}) }; + + for (const ref of refs.apikeyRefs) { + if (values[ref] === undefined) values[ref] = "user.token"; + } + + if (refs.apiKeySecretRefs.length > 0) { + const userSupplied = + (b.api_key_secret as string | undefined) ?? + (input.api_key_secret as string | undefined); + const fallback = input[CONFIG_API_KEY] as string | undefined; + const keyValue = userSupplied || fallback; + if (!keyValue) { + throw new Error( + "Missing apiKeySecret. This workflow has a step with an apiKeySecret input but no api_key_secret was provided and HARNESS_API_KEY is not configured. Pass apiKeySecret in the body.", + ); + } + + for (const ref of refs.apiKeySecretRefs) { + if (values[ref] === undefined) values[ref] = keyValue; + } + } + + const requestBody = { identifier, values }; + const loggedValues = { ...values }; + for (const ref of refs.apiKeySecretRefs) { + if (loggedValues[ref] !== undefined) loggedValues[ref] = "[REDACTED]"; + } + console.error( + "[idp_workflow.execute] final request body", + JSON.stringify({ identifier, values: loggedValues }), + ); + return requestBody; + }, responseExtractor: ngExtract, - actionDescription: "Execute an IDP self-service workflow", + actionDescription: + "Execute a workflow in the Harness IDP Catalog.\n\n" + + "Required inputs:\n" + + "- workflow_details: full workflow entity. Fetch FIRST via harness_get(resource_type=idp_entity, kind=workflow, entity_id=) and pass the result. Required so the tool can inspect spec.steps[] and inject the correct values for HarnessAuthToken-style parameters.\n" + + "- identifier: workflow identifier (or pass via resource_id; auto-extracted from workflow_details.identifier).\n" + + "- values: user-supplied values for the workflow's spec.parameters (validated against required fields). OMIT any parameter whose ui:field is HarnessAuthToken — the tool auto-fills those.\n\n" + + "Optional input:\n" + + "- api_key_secret: user-supplied API key. Required only when the workflow has a step input named \"apiKeySecret\" AND HARNESS_API_KEY is not configured (e.g. when the server runs in JWT-only mode). Otherwise the tool falls back to HARNESS_API_KEY.\n\n" + + "Auto-injection rules per step in spec.steps[]:\n" + + "- step.input.apikey: ${{ parameters.X }} -> values[X] = constant placeholder (\"user.token\")\n" + + "- step.input.apiKeySecret: ${{ parameters.Y }} -> values[Y] = api_key_secret (or HARNESS_API_KEY fallback)\n\n" + + "Do NOT execute if required parameters in spec.parameters[] are missing from values.", bodySchema: { - description: "Workflow execution inputs", + description: "Workflow execution inputs.", fields: [ - { name: "inputs", type: "object", required: false, description: "Key-value inputs for the workflow" }, + { + name: "workflow_details", + type: "object", + required: false, + description: + "Required tool input, not sent to the Harness API. A json representation of the workflow entity. This json contains the metadata of the workflow(like owner, name, description, ref etc) and a yaml field which should contain the spec.parameters against which the values should be validated. Only the parameters marked required are mandatory.", + }, + { + name: "identifier", + type: "string", + required: true, + description: "The identifier of the workflow to execute. This can be extracted from workflow_details.identifier or passed as resource_id.", + }, + { + name: "values", + type: "object", + required: false, + description: + "User-supplied workflow parameter values. Validate required fields from workflow_details.yaml spec.parameters. Omit auth-token parameters; the tool auto-fills step.input.apikey and step.input.apiKeySecret references.", + }, + { + name: "api_key_secret", + type: "string", + required: false, + description: + "Optional API key used to fill parameters referenced by step.input.apiKeySecret. Falls back to HARNESS_API_KEY when omitted. Required when such a step exists and HARNESS_API_KEY is unavailable.", + }, ], }, }, @@ -239,21 +549,31 @@ export const idpToolset: ToolsetDefinition = { { resourceType: "idp_tech_doc", displayName: "IDP Tech Doc", - description: "Search IDP TechDocs documentation via semantic search. Supports list (search).", + description: "Semantic search across documentation for entities in the Harness IDP — services, APIs, workflows, user groups, environments. Returns ranked documents with content and the corresponding entity_id. Use this for any general 'how do I…' question that internal documentation may answer (debug a failing workflow, install steps, configuration details, monitoring an entity, etc.).", toolset: "idp", scope: "account", identifierFields: [], + searchAliases: ["techdocs", "tech docs", "documentation", "docs", "knowledge"], listFilterFields: [ - { name: "query", description: "Search query for TechDocs" }, + { name: "query", description: "The semantic search query (e.g. 'how to troubleshoot a failing workflow?'). Falls back to the standard search_term when omitted." }, ], operations: { list: { method: "POST", path: "/v1/tech-docs/semantic-search", operationPolicy: { risk: "read", retryPolicy: "safe" }, - bodyBuilder: (input) => ({ query: input.query ?? input.search_term ?? "" }), - responseExtractor: v1ListExtract(), - description: "Search IDP TechDocs via semantic search", + bodyBuilder: (input) => { + const query = (input.query as string | undefined) || (input.search_term as string | undefined); + if (!query) { + throw new Error( + "Missing required field 'query' for idp_tech_doc search. " + + "Pass it via filters: { query: '...' } or use the standard search_term.", + ); + } + return { query }; + }, + responseExtractor: passthrough, + description: "Semantically search IDP TechDocs. Returns documents ranked by relevance, each with content + entity_id. Use to answer any generic question about Harness entities — the documentation may match relevant docs even when the query wording differs.", }, }, },