diff --git a/README.md b/README.md index 39ac032c..3867e5bf 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 229 resource types. ## Why Use This MCP Server @@ -10,7 +10,7 @@ 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. +- **11 tools, 229 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. - **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. @@ -1202,7 +1202,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. +229 resource types organized across 38 toolsets. Each resource type supports a subset of CRUD operations and optional execute actions. ### Platform @@ -1422,13 +1422,15 @@ Template operations use the Harness Template service paths (`/template/api/templ ### Infrastructure as Code Management (IaCM) -IaCM resources are default-enabled and mostly project-scoped. Start with `iacm_workspace` to find workspace identifiers, then use that `workspace_id` for workspace resources, costs, and activity diffs. Use `iacm_variable_set` for reusable variable sets at account, org, or project scope. +IaCM resources are default-enabled and mostly project-scoped. Start with `iacm_workspace` to find workspace identifiers, then use that `workspace_id` for workspace resources, costs, and activity diffs. Use `iacm_variable_set` for reusable variable sets at account, org, or project scope. The provider registry is account-scoped. -`iacm_module` also spans account, org, and project scope. It defaults to the account registry; every operation (list, get, create, update) sends the same `scope_org` / `scope_project` query params, so a module you create is discoverable at the scope you created it. Select the scope with `resource_scope="account" | "org" | "project"` plus `org_id`/`project_id`. Scoping is opt-in: when `resource_scope` is omitted, `org_id`/`project_id` apply only if you pass them explicitly — configured `HARNESS_ORG`/`HARNESS_PROJECT` defaults are not applied, so an ambient project config cannot silently register an account module under a project. A module body's own `org`/`project` fields locate its Git connector and are unrelated to this visibility scope. +`iacm_module` spans account, org, and project scope. It defaults to the account registry; every operation (list, get, create, update) sends the same `scope_org` / `scope_project` query params, so a module you create is discoverable at the scope you created it. Select the scope with `resource_scope="account" | "org" | "project"` plus `org_id`/`project_id`. Scoping is opt-in: when `resource_scope` is omitted, `org_id`/`project_id` apply only if you pass them explicitly — configured `HARNESS_ORG`/`HARNESS_PROJECT` defaults are not applied, so an ambient project config cannot silently register an account module under a project. A module body's own `org`/`project` fields locate its Git connector and are unrelated to this visibility scope. -`iacm_workspace` create/update return `{ policy_evaluation }` only — follow up with `harness_get` to fetch the workspace. `iacm_variable_set` and `iacm_module` create/update return the resource itself. Variable-set **update is HTTP PUT with full-replacement collections** — always `harness_get` first, then PUT the full desired body (`terraform_variables` / `environment_variables` are required on update; omit/empty clears connectors and variable files). Module update is also PUT — prefer get-then-put for optional fields. Writes are `medium_write` and require confirmation (elicitation or `confirm: true`). +`iacm_workspace` create/update return `{ policy_evaluation }` only — follow up with `harness_get` to fetch the workspace. `iacm_variable_set` and `iacm_module` create/update return the resource itself. `iacm_provider` create returns `{ id }` only — follow up with `harness_get`; **update is version-oriented only** (POST/PUT `/providers/{id}/version`) — there is no metadata PUT. Version writes may return an empty body; HarnessClient normalizes that to `{ status: "SUCCESS", message: "No content" }`. -Variable-set RBAC (`iac_variableset_*`) is currently Experimental in Harness — access checks always allow until iac-server activates enforcement. Module registry RBAC (`iac_registry_view` / `iac_registry_edit`) is Active and enforceable. MCP always forwards the caller PAT/SAT unchanged. +Variable-set **update is HTTP PUT with full-replacement collections** — always `harness_get` first, then PUT the full desired body (`terraform_variables` / `environment_variables` are required on update; omit/empty clears connectors and variable files). Module update is also PUT — prefer get-then-put for optional fields. Writes are `medium_write` and require confirmation (elicitation or `confirm: true`). + +Variable-set and provider-registry RBAC (`iac_variableset_*`, `iac_providerregistry_*`) are currently Experimental in Harness — access checks always allow until iac-server activates enforcement. Module registry RBAC (`iac_registry_view` / `iac_registry_edit`) is Active and enforceable. MCP always forwards the caller PAT/SAT unchanged. | Resource Type | List | Get | Create | Update | Delete | Execute Actions | | ------------------------------- | ---- | --- | ------ | ------ | ------ | --------------- | @@ -1436,6 +1438,7 @@ Variable-set RBAC (`iac_variableset_*`) is currently Experimental in Harness — | `iacm_variable_set` | x | x | x | x | | | | `iacm_resource` | x | | | | | | | `iacm_module` | x | x | x | x | | | +| `iacm_provider` | x | x | x | x | | | | `iacm_workspace_costs` | x | | | | | | | `iacm_activity_resource_change` | x | | | | | | @@ -1446,9 +1449,10 @@ Typical workflow: 3. `harness_get(resource_type="iacm_workspace", workspace_id="...")` to fetch the created/updated workspace. 4. `harness_list` / `harness_create` / `harness_update` on `iacm_variable_set` (optionally with `resource_scope`) for reusable Terraform/env variable sets — response is the VariableSet resource. 5. `harness_list` / `harness_create` / `harness_update` on `iacm_module` for the module registry (`name` + `system` required; add `resource_scope` with `org_id`/`project_id` for an org- or project-scoped module) — response is the module resource. -6. `harness_list(resource_type="iacm_resource", org_id="...", project_id="...", workspace_id="...")` to inspect Terraform resources, outputs, and data sources. -7. `harness_list(resource_type="iacm_workspace_costs", org_id="...", project_id="...", workspace_id="...")` to review per-execution cost entries. -8. `harness_list(resource_type="iacm_activity_resource_change", org_id="...", project_id="...", activity_id="...", workspace_id="...")` to inspect before/after resource diffs for a plan, apply, or destroy activity. +6. `harness_list` / `harness_create` / `harness_update` on `iacm_provider` for the account provider registry (`body.type` required for create; create returns `{ id }` only — then `harness_get`; update creates/updates **versions** only) — version update may return empty success. +7. `harness_list(resource_type="iacm_resource", org_id="...", project_id="...", workspace_id="...")` to inspect Terraform resources, outputs, and data sources. +8. `harness_list(resource_type="iacm_workspace_costs", org_id="...", project_id="...", workspace_id="...")` to review per-execution cost entries. +9. `harness_list(resource_type="iacm_activity_resource_change", org_id="...", project_id="...", activity_id="...", workspace_id="...")` to inspect before/after resource diffs for a plan, apply, or destroy activity. IaCM list responses expose `page_count` as the count for the current page only (except `iacm_variable_set`, which is not paginated). When `has_more` is true, keep requesting the next 1-based page and sum page counts if you need a total. @@ -1869,7 +1873,7 @@ Available toolset names: | `knowledge-graph` | kg_queryable_type_summary, kg_grammar, hql_query | | `semantic-layer` | kg_type, kg_related_type | | `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 | +| `iacm` | iacm_workspace, iacm_variable_set, iacm_resource, iacm_module, iacm_provider, iacm_workspace_costs, iacm_activity_resource_change | | `ansible` *(opt-in)* | ansible_inventory, ansible_playbook, ansible_host, ansible_host_activity, ansible_activity | @@ -1889,7 +1893,7 @@ Available toolset names: +--------v---------+ | Registry | <-- Declarative resource definitions | 38 Toolsets | (data files, not code) - | 228 Resource Types| + | 229 Resource Types| +--------+---------+ | +--------v---------+ diff --git a/src/client/harness-client.ts b/src/client/harness-client.ts index 18ace3e0..0ad73ca4 100644 --- a/src/client/harness-client.ts +++ b/src/client/harness-client.ts @@ -374,11 +374,6 @@ export class HarnessClient { throw error; } - // 204 No Content — valid success response (e.g. PATCH/DELETE on PM API) - if (response.status === 204) { - return { status: "SUCCESS", message: "No content" } as T; - } - // Binary response mode — return raw ArrayBuffer (used for ZIP downloads) if (options.responseType === "buffer") { const buffer = await response.arrayBuffer(); @@ -387,11 +382,10 @@ export class HarnessClient { } const text = await response.text(); + // Empty body on 2xx is a valid success (204 No Content; also 201 from + // IaCM provider-registry create/update version, which returns no JSON). if (!text) { - throw new HarnessApiError( - `Empty response body from ${method} ${options.path}`, - 502, - ); + return { status: "SUCCESS", message: "No content" } as T; } let data: unknown; try { diff --git a/src/registry/toolsets/iacm.ts b/src/registry/toolsets/iacm.ts index 52510196..3e618fcb 100644 --- a/src/registry/toolsets/iacm.ts +++ b/src/registry/toolsets/iacm.ts @@ -141,6 +141,27 @@ const moduleListExtract = ( /** Module get/create/update return the module resource (or create result) directly. */ const moduleExtract = (raw: unknown): unknown => raw; +/** + * Provider list: API returns a raw JSON array of provider objects. + */ +const providerListExtract = ( + raw: unknown, +): { items: unknown[]; page_count: number; has_more: boolean; pagination_note: string } => { + const items = Array.isArray(raw) ? raw : []; + const has_more = items.length >= IACM_PAGE_SIZE; + return { + items, + page_count: items.length, + has_more, + pagination_note: has_more + ? `Only ${items.length} providers returned (page is full). Call again with page+1 to fetch the next batch. Do NOT report ${items.length} as the total provider count.` + : `All providers on this page returned (${items.length} items). has_more=false means this is the last page.`, + }; +}; + +/** Provider get/create/version ops return the API payload directly. */ +const providerExtract = (raw: unknown): unknown => raw; + /** * IACM workspace costs: API returns a raw JSON array of cost entries. * Costs are typically a small finite list so has_more acts as a safety signal only. @@ -259,6 +280,52 @@ const variableSetItemPath = ( return `${variableSetCollectionPath(input, config)}/${encodeURIComponent(id)}`; }; +const providerCreatePath = (input: Record): string => { + const body = (input.body as Record | undefined) ?? {}; + const type = (input.type as string | undefined) ?? (body.type as string | undefined); + if (!type) { + throw new Error( + 'Missing required field "type" for iacm_provider create. Pass it in body.type (e.g. "aws", "terraform").', + ); + } + return `/iacm/api/providers/${encodeURIComponent(type)}`; +}; + +const providerCreateBody = (input: Record): Record => { + const body = { ...((input.body as Record | undefined) ?? {}) }; + delete body.type; + return body; +}; + +const providerVersionMethod = (input: Record): "POST" | "PUT" => + input.version ? "PUT" : "POST"; + +const providerVersionPath = (input: Record): string => { + const id = input.id as string | undefined; + if (!id) { + throw new Error( + 'Missing required field "id" for iacm_provider update. Pass the provider id via params or as resource_id.', + ); + } + if (input.version) { + return `/iacm/api/providers/${encodeURIComponent(id)}/version/${encodeURIComponent(String(input.version))}`; + } + return `/iacm/api/providers/${encodeURIComponent(id)}/version`; +}; + +const providerVersionBody = (input: Record): Record => { + const body = { ...((input.body as Record | undefined) ?? {}) }; + if (input.version) { + delete body.version; + } else if (body.version === undefined || body.version === "") { + throw new Error( + 'Missing required field "version" for iacm_provider version create. ' + + "Pass version in body when creating a new version, or pass params.version to update an existing version.", + ); + } + return body; +}; + // ─── Workspace request schemas ─────────────────────────────────────────────── const workspaceVariableValueFields: BodyFieldSpec[] = [ @@ -633,6 +700,49 @@ const moduleUpdateSchema: BodySchema = { ], }; +const providerCreateSchema: BodySchema = { + // type is REQUIRED at the call layer (enforced by pathBuilder), but declared required:false + // here because bodySchema validates the BUILT body — and providerCreateBody strips type + // from the wire payload (type is a path segment, not a JSON field). + description: + "IaCM provider registry create definition. Account-scoped (Harness-Account header only). " + + "body.type is REQUIRED (path segment, e.g. aws); only description is forwarded in the JSON body. " + + "API response is { id } only — call harness_get with that id for full details.", + fields: [ + { + name: "type", + type: "string", + required: false, + description: + "REQUIRED (enforced by pathBuilder). Provider type path segment (e.g. aws, terraform). Stripped from the JSON body.", + }, + { name: "description", type: "string", required: false, description: "Optional provider description" }, + ], +}; + +const providerUpdateSchema: BodySchema = { + description: + "Version-oriented provider update. There is no metadata PUT /providers/{id}. " + + "Omit params.version to POST a new version (include body.version). " + + "Pass params.version to PUT an existing version. Version writes may return an empty 201/204 body.", + fields: [ + { + name: "version", + type: "string", + required: false, + description: "Required in body when creating a new version (no params.version). Omitted from body on PUT update.", + }, + { + name: "protocol", + type: "array", + required: true, + itemType: "string", + description: "Supported Terraform/OpenTofu protocol versions (e.g. [\"5.0\"])", + }, + { name: "gpg_key_id", type: "string", required: true, description: "GPG signing key id for the provider version" }, + ], +}; + // ─── Toolset definition ───────────────────────────────────────────────────── export const iacmToolset: ToolsetDefinition = { @@ -641,10 +751,10 @@ export const iacmToolset: ToolsetDefinition = { description: "Harness IaCM (Infrastructure as Code Management) — manage Terraform workspaces " + "(list/get/create/update), shared variable sets, provisioned resources and Terraform outputs, " + - "the module registry, workspace cost history, and resource-change diffs from plan/apply/destroy. " + + "the module and provider registries, workspace cost history, and resource-change diffs from plan/apply/destroy. " + "Use iacm_workspace to list, get, create, or update workspaces; iacm_variable_set for reusable " + "variable sets (account/org/project); iacm_resource for Terraform resources and outputs; " + - "iacm_module to list/get/create/update the module registry (account/org/project); iacm_workspace_costs for cost breakdown; " + + "iacm_module (account/org/project) and iacm_provider for private registries; iacm_workspace_costs for cost breakdown; " + "and iacm_activity_resource_change for activity diffs.", optIn: false, resources: [ @@ -1064,6 +1174,115 @@ export const iacmToolset: ToolsetDefinition = { }, }, + // ─── Provider Registry ───────────────────────────────────────────────── + { + resourceType: "iacm_provider", + displayName: "IaCM Provider Registry", + description: + "Private Terraform/OpenTofu providers registered in the Harness IaCM provider registry. " + + "SCOPE: account-only — the IaCM provider-registry API has no scope_org/scope_project params " + + "(unlike iacm_module). Ambient org_id/project_id and HARNESS_ORG/HARNESS_PROJECT are ignored. " + + "Providers are identified by numeric/string id (not type alone). " + + "USER FLOW: harness_create → response is { id } only → harness_get/harness_list with that id → " + + "harness_update for versions (not metadata). " + + "IMPORTANT: there is no metadata PUT /providers/{id}. harness_update is version-oriented: " + + "omit params.version to create a new version (POST), or pass params.version to update an existing version (PUT). " + + "Version create/update may return an empty body (HarnessClient normalizes to success). " + + "Provider-registry RBAC (iac_providerregistry_*) is Experimental. " + + "PAGINATION: Results are capped at 30 per page (1-based). page_count is THIS page only.", + toolset: "iacm", + scope: "account", + identifierFields: ["id"], + listFilterFields: [ + { name: "search_term", description: "Filter providers by partial name match", type: "string" }, + { + name: "sort", + description: "Sort order", + type: "string", + enum: ["name,ASC", "name,DESC", "updated,ASC", "updated,DESC"], + }, + { name: "page", description: "Page number (1-based). Default: 1.", type: "number" }, + { name: "size", description: "Page size (mapped to API limit). Default: 30.", type: "number" }, + ], + operations: { + list: { + method: "GET", + path: "/iacm/api/providers", + queryParams: { + page: "page", + size: "limit", + search_term: "searchTerm", + sort: "sort", + }, + pageOneIndexed: true, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + responseExtractor: providerListExtract, + description: + "List providers in the account-scoped IaCM provider registry (30 per page; page >= 1). " + + "org_id/project_id are ignored — this API has no scope_org/scope_project. " + + "Response fields: items, page_count (THIS page only), has_more, pagination_note.", + }, + get: { + method: "GET", + path: "/iacm/api/providers/{providerId}", + pathParams: { id: "providerId" }, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + responseExtractor: providerExtract, + description: + "Get a provider by id. Use the id from harness_create (response.id) or harness_list — " + + "not the provider type string alone. Account-scoped; org_id/project_id are ignored.", + }, + create: { + method: "POST", + path: "/iacm/api/providers/{type}", + pathBuilder: providerCreatePath, + operationPolicy: { risk: "medium_write", retryPolicy: "do_not_retry" }, + bodyBuilder: providerCreateBody, + bodySchema: providerCreateSchema, + skipScopeBodyInjection: true, + responseExtractor: providerExtract, + description: + "Create a provider registry entry. Required body.type becomes the path segment; optional body.description is sent as JSON. " + + "Response is { id } only — not the full provider. Follow up with harness_get " + + "(resource_type=iacm_provider, id=) or harness_list before version updates. " + + "Account-scoped (no org/project query params). " + + "Provider-registry RBAC is Experimental (iac_providerregistry_edit). medium_write — requires confirmation.", + }, + update: { + method: "PUT", + path: "/iacm/api/providers/{providerId}/version/{version}", + methodBuilder: providerVersionMethod, + pathBuilder: providerVersionPath, + operationPolicy: { risk: "medium_write", retryPolicy: "do_not_retry" }, + bodyBuilder: providerVersionBody, + bodySchema: providerUpdateSchema, + skipScopeBodyInjection: true, + responseExtractor: providerExtract, + paramsSchema: { + fields: [ + { + name: "id", + required: true, + description: "Provider id from create/list (also accepted as resource_id) — not the type string", + }, + { + name: "version", + required: false, + description: + "When set, updates that existing version via PUT. When omitted, creates a new version via POST (include body.version).", + }, + ], + }, + description: + "Version-oriented provider update — NOT a metadata PATCH. Use the id from harness_create/harness_list (not type). " + + "Create version: omit params.version and pass body.version + protocol + gpg_key_id (POST /providers/{id}/version). " + + "Update version: pass params.version with body.protocol + gpg_key_id (PUT /providers/{id}/version/{version}). " + + "Version writes may return an empty 201/204 body; HarnessClient returns { status: \"SUCCESS\", message: \"No content\" }. " + + "Account-scoped (no org/project query params). Provider-registry RBAC is Experimental. medium_write — requires confirmation.", + }, + }, + }, + // ─── Workspace Costs ─────────────────────────────────────────────────── { resourceType: "iacm_workspace_costs", diff --git a/tasks/todo.md b/tasks/todo.md index 914ad248..a7492dae 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -37,7 +37,7 @@ - Create/update return the VariableSet resource (not policy_evaluation). - Do not claim deny-path RBAC coverage while permissions are Experimental. -## IaCM module registry create/update (#810 — rebased onto main) +## IaCM module registry create/update (merged #810) - [x] Add `iacm_module` create/update with name/system body schemas - [x] Polish tests (wiring, validation, mock-fetch, MCP elicitation) + README - [x] Note Active RBAC: `iac_registry_view` / `iac_registry_edit` are enforceable @@ -60,6 +60,24 @@ params on `GET /iacm/api/modules`, and page size is `limit`, not `size`. Replace with `searchTerm` / `sort` / `limit` in a separate read-path PR. +## IaCM provider registry list/get/create/version update (#811 — rebased onto main) +- [x] Add `iacm_provider` list/get/create and version-oriented update (POST/PUT `/providers/{id}/version`) +- [x] Fix HarnessClient empty 2xx body handling for provider version writes +- [x] Polish tests (wiring, validation, mock-fetch, MCP elicitation) + README +- [x] Note Experimental RBAC: `iac_providerregistry_*` always permitted until iac-server enforces +- [x] Open PR — https://github.com/harness/mcp-server/pull/811 +- [x] Rebase onto `main` after #810 merged so the delta is provider-only +- [x] Cross-repo audit: confirm account-only (no scope_*); create returns `{ id }` only; version writes empty 201 + +### Plan +- Provider registry is account-scoped by API design (no scope_org/scope_project) — do not + copy the module multi-scope pattern here. +- Create maps `body.type` to the path segment (not JSON); response is `{ id }` only — + agents must harness_get/list before version updates. +- There is no metadata PUT — `harness_update` creates or updates provider **versions** only. +- Version create/update may return empty 201/204; HarnessClient normalizes to `{ status: "SUCCESS", message: "No content" }`. +- Provider-registry RBAC is Experimental — do not claim deny-path coverage; MCP still forwards the caller token. + ## Dependency security advisories (2026-08-03) - [x] Confirm affected dependency chains and fixed versions for `fast-uri` and `ip-address` - [x] Update pnpm overrides and regenerate both dependency lockfiles diff --git a/tests/client/harness-client.test.ts b/tests/client/harness-client.test.ts index dde7aaa7..d499dd42 100644 --- a/tests/client/harness-client.test.ts +++ b/tests/client/harness-client.test.ts @@ -859,18 +859,12 @@ describe("HarnessClient", () => { } }); - it("throws clear error for empty response body", async () => { - fetchSpy.mockResolvedValue(new Response("", { status: 200 })); + it("treats empty 2xx body as success (204 / IaCM provider version 201)", async () => { + fetchSpy.mockResolvedValue(new Response("", { status: 201 })); const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 0 })); - try { - await client.request({ path: "/test" }); - expect.fail("should have thrown"); - } catch (err) { - expect(err).toBeInstanceOf(HarnessApiError); - expect((err as HarnessApiError).statusCode).toBe(502); - expect((err as HarnessApiError).message).toContain("Empty response body"); - } + const result = await client.request({ path: "/test" }); + expect(result).toEqual({ status: "SUCCESS", message: "No content" }); }); it("parses valid JSON response normally", async () => { diff --git a/tests/integration/elicitation-flow.test.ts b/tests/integration/elicitation-flow.test.ts index 1a96e384..f803f4e6 100644 --- a/tests/integration/elicitation-flow.test.ts +++ b/tests/integration/elicitation-flow.test.ts @@ -492,6 +492,37 @@ describe("Elicitation flow: iacm_workspace medium_write", () => { system: "aws", }); }); + + it("elicits confirmation for iacm_provider medium_write create", async () => { + mockRequest = vi.fn().mockResolvedValue({ + id: "1", + type: "aws", + }); + client = { + request: mockRequest, + account: "test-account", + } as unknown as HarnessClient; + + const server = makeMcpServer({ supportsElicitation: true, elicitAction: "accept" }); + const { registerCreateTool } = await import("../../src/tools/harness-create.js"); + registerCreateTool(server, registry, client, makeConfig({ HARNESS_TOOLSETS: "iacm" })); + + const result = await server.call("harness_create", { + resource_type: "iacm_provider", + body: { + type: "aws", + description: "AWS provider", + }, + }); + + expect(result.isError).toBeUndefined(); + expect(server._elicitInput).toHaveBeenCalledOnce(); + expect(mockRequest).toHaveBeenCalledOnce(); + expect(JSON.parse(result.content[0]!.text)).toEqual({ + id: "1", + type: "aws", + }); + }); }); describe("Elicitation ordering: validate before elicit", () => { diff --git a/tests/integration/mock-harness-api.test.ts b/tests/integration/mock-harness-api.test.ts index 0039f231..8e841e6c 100644 --- a/tests/integration/mock-harness-api.test.ts +++ b/tests/integration/mock-harness-api.test.ts @@ -746,4 +746,115 @@ describe("Integration: Registry → HarnessClient → fetch", () => { expect(getUrlStr).toContain("scope_project=test-project"); }); }); + + describe("iacm provider registry writes", () => { + it("create posts type in path and returns { id } only", async () => { + const created = { id: "1" }; + fetchSpy.mockResolvedValueOnce(mockFetchResponse(created, 201)); + + const config = makeConfig({ HARNESS_TOOLSETS: "iacm" }); + const client = new HarnessClient(config); + const registry = new Registry(config); + + const result = await registry.dispatch(client, "iacm_provider", "create", { + body: { type: "aws", description: "AWS provider" }, + }); + + expect(fetchSpy).toHaveBeenCalledOnce(); + const [url, options] = fetchSpy.mock.calls[0]!; + const urlStr = url instanceof URL ? url.toString() : String(url); + expect(urlStr).toContain("/iacm/api/providers/aws"); + expect(urlStr).not.toContain("scope_org"); + expect(urlStr).not.toContain("scope_project"); + expect(urlStr).not.toContain("orgIdentifier"); + expect((options as RequestInit).method?.toUpperCase()).toBe("POST"); + expect(JSON.parse((options as RequestInit).body as string)).toEqual({ + description: "AWS provider", + }); + expect(result).toEqual(created); + }); + + it("create/list/get ignore ambient org/project (account-only API)", async () => { + fetchSpy + .mockResolvedValueOnce(mockFetchResponse({ id: "1" }, 201)) + .mockResolvedValueOnce(mockFetchResponse([{ id: "1", type: "aws" }])) + .mockResolvedValueOnce(mockFetchResponse({ id: "1", type: "aws" })); + + // makeConfig sets HARNESS_ORG/HARNESS_PROJECT — must not leak onto provider URLs. + const config = makeConfig({ HARNESS_TOOLSETS: "iacm" }); + const client = new HarnessClient(config); + const registry = new Registry(config); + + await registry.dispatch(client, "iacm_provider", "create", { + org_id: "default", + project_id: "test-project", + body: { type: "aws" }, + }); + await registry.dispatch(client, "iacm_provider", "list", { + org_id: "default", + project_id: "test-project", + }); + await registry.dispatch(client, "iacm_provider", "get", { + id: "1", + org_id: "default", + project_id: "test-project", + }); + + for (const call of fetchSpy.mock.calls) { + const urlStr = call[0] instanceof URL ? call[0].toString() : String(call[0]); + expect(urlStr).not.toContain("scope_org"); + expect(urlStr).not.toContain("scope_project"); + expect(urlStr).not.toContain("orgIdentifier"); + expect(urlStr).not.toContain("projectIdentifier"); + } + }); + + it("update posts a new version and accepts empty 201 bodies", async () => { + fetchSpy.mockResolvedValueOnce(new Response("", { status: 201 })); + + const config = makeConfig({ HARNESS_TOOLSETS: "iacm" }); + const client = new HarnessClient(config); + const registry = new Registry(config); + + const result = await registry.dispatch(client, "iacm_provider", "update", { + id: "1", + body: { version: "1.0.0", protocol: ["5.0"], gpg_key_id: "key-1" }, + }); + + const [url, options] = fetchSpy.mock.calls[0]!; + const urlStr = url instanceof URL ? url.toString() : String(url); + expect(urlStr).toContain("/iacm/api/providers/1/version"); + expect((options as RequestInit).method?.toUpperCase()).toBe("POST"); + expect(JSON.parse((options as RequestInit).body as string)).toEqual({ + version: "1.0.0", + protocol: ["5.0"], + gpg_key_id: "key-1", + }); + expect(result).toEqual({ status: "SUCCESS", message: "No content" }); + }); + + it("update puts an existing version by params.version", async () => { + fetchSpy.mockResolvedValueOnce(new Response("", { status: 201 })); + + const config = makeConfig({ HARNESS_TOOLSETS: "iacm" }); + const client = new HarnessClient(config); + const registry = new Registry(config); + + const result = await registry.dispatch(client, "iacm_provider", "update", { + id: "1", + version: "1.0.0", + body: { protocol: ["5.0"], gpg_key_id: "key-1" }, + }); + + const [url, options] = fetchSpy.mock.calls[0]!; + const urlStr = url instanceof URL ? url.toString() : String(url); + expect(urlStr).toContain("/iacm/api/providers/1/version/1.0.0"); + expect((options as RequestInit).method?.toUpperCase()).toBe("PUT"); + expect(JSON.parse((options as RequestInit).body as string)).toEqual({ + protocol: ["5.0"], + gpg_key_id: "key-1", + }); + expect(result).toEqual({ status: "SUCCESS", message: "No content" }); + }); + }); }); diff --git a/tests/registry/iacm.test.ts b/tests/registry/iacm.test.ts index fd2c442a..5b0eaca9 100644 --- a/tests/registry/iacm.test.ts +++ b/tests/registry/iacm.test.ts @@ -62,7 +62,7 @@ describe("iacmToolset structure", () => { expect(iacmToolset.optIn).toBe(false); }); - it("registers all 6 resource types", () => { + it("registers all 7 resource types", () => { const types = iacmToolset.resources.map((r) => r.resourceType); expect(types).toContain("iacm_workspace"); expect(types).toContain("iacm_resource"); @@ -70,7 +70,8 @@ describe("iacmToolset structure", () => { expect(types).toContain("iacm_workspace_costs"); expect(types).toContain("iacm_activity_resource_change"); expect(types).toContain("iacm_variable_set"); - expect(types).toHaveLength(6); + expect(types).toContain("iacm_provider"); + expect(types).toHaveLength(7); }); it("iacm_module defaults to account scope but supports org and project", () => { @@ -83,6 +84,16 @@ describe("iacmToolset structure", () => { expect(resource.scopeParams).toEqual({ org: "scope_org", project: "scope_project" }); }); + it("iacm_provider is account-scoped only (no multi-scope like modules)", () => { + const resource = findResource("iacm_provider"); + expect(resource.scope).toBe("account"); + expect(resource.supportedScopes).toBeUndefined(); + expect(resource.scopeOptional).toBeUndefined(); + expect(resource.scopeParams).toBeUndefined(); + expect(resource.description).toMatch(/account-only|account-scoped/i); + expect(resource.description).toMatch(/\{ id \} only|response\.id/i); + }); + it("iacm_module list has pageOneIndexed=true", () => { expect(getOp("iacm_module", "list").pageOneIndexed).toBe(true); }); @@ -328,6 +339,91 @@ describe("iacm_module write contract", () => { }); }); +// ─── Provider registry contract ────────────────────────────────────────────── + +describe("iacm_provider contract", () => { + it("registers list/get/create and version-oriented update", () => { + const list = getOp("iacm_provider", "list"); + const get = getOp("iacm_provider", "get"); + const create = getOp("iacm_provider", "create"); + const update = getOp("iacm_provider", "update"); + + expect(list.path).toBe("/iacm/api/providers"); + expect(list.pageOneIndexed).toBe(true); + expect(list.queryParams).toMatchObject({ + page: "page", + size: "limit", + search_term: "searchTerm", + sort: "sort", + }); + + expect(get.path).toBe("/iacm/api/providers/{providerId}"); + expect(get.pathParams).toEqual({ id: "providerId" }); + + expect(create.method).toBe("POST"); + expect(create.pathBuilder).toBeTypeOf("function"); + expect(create.operationPolicy).toEqual({ + risk: "medium_write", + retryPolicy: "do_not_retry", + }); + + expect(update.methodBuilder).toBeTypeOf("function"); + expect(update.pathBuilder).toBeTypeOf("function"); + expect(update.operationPolicy).toEqual({ + risk: "medium_write", + retryPolicy: "do_not_retry", + }); + }); + + it("documents create provider and version update body requirements", () => { + // type is call-layer required but schema-optional: bodyBuilder strips it before + // bodySchema validation (same pattern as STO exemption scope). + const createRequired = getOp("iacm_provider", "create") + .bodySchema!.fields.filter((field) => field.required) + .map((field) => field.name); + const createFields = getOp("iacm_provider", "create") + .bodySchema!.fields.map((field) => field.name); + const updateRequired = getOp("iacm_provider", "update") + .bodySchema!.fields.filter((field) => field.required) + .map((field) => field.name); + + expect(createFields).toEqual(["type", "description"]); + expect(createRequired).toEqual([]); + expect(updateRequired).toEqual(["protocol", "gpg_key_id"]); + }); + + it("normalizes provider list arrays like other IaCM registries", () => { + const extract = getOp("iacm_provider", "list").responseExtractor!; + const result = extract([{ id: "1", type: "aws" }]) as Record; + expect(result.items).toEqual([{ id: "1", type: "aws" }]); + expect(result.page_count).toBe(1); + expect(result.has_more).toBe(false); + }); + + it("documents Experimental provider-registry RBAC and version-oriented update", () => { + const create = getOp("iacm_provider", "create"); + const update = getOp("iacm_provider", "update"); + expect(create.description).toMatch(/\{ id \} only/i); + expect(create.description).toMatch(/harness_get/i); + expect(create.description).toMatch(/Experimental/i); + expect(create.description).not.toMatch(/Active.*iac_registry/i); + expect(create.bodySchema!.description).toMatch(/\{ id \} only/i); + expect(update.description).toMatch(/version-oriented|NOT a metadata PATCH/i); + expect(update.description).toMatch(/Experimental/i); + expect(update.description).toMatch(/empty|No content/i); + expect(update.bodySchema!.description).toMatch(/no metadata PUT|POST a new version/i); + }); + + it("supports only account scope (org/project resource_scope is rejected)", async () => { + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "iacm" })); + const client = makeClient(); + expect(registry.getSupportedScopes("iacm_provider")).toEqual(["account"]); + await expect( + registry.dispatch(client, "iacm_provider", "list", { resource_scope: "org", org_id: "default" }), + ).rejects.toThrow(/does not support org scope/i); + }); +}); + // ─── Variable set contract ─────────────────────────────────────────────────── describe("iacm_variable_set contract", () => { @@ -437,6 +533,7 @@ describe("iacm default-on with Registry", () => { expect(registry.getAllResourceTypes()).toContain("iacm_workspace_costs"); expect(registry.getAllResourceTypes()).toContain("iacm_activity_resource_change"); expect(registry.getAllResourceTypes()).toContain("iacm_variable_set"); + expect(registry.getAllResourceTypes()).toContain("iacm_provider"); }); it("IS present when enabled with +iacm modifier", () => { @@ -1163,4 +1260,58 @@ describe("iacm registry dispatch", () => { ).rejects.toThrow("system"); expect(mockRequest).not.toHaveBeenCalled(); }); + + it("dispatches provider list/get/create and version create/update", async () => { + const mockRequest = vi.fn() + .mockResolvedValueOnce([{ id: "1", type: "aws" }]) + .mockResolvedValueOnce({ id: "1", type: "aws" }) + .mockResolvedValueOnce({ id: "1" }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "iacm" })); + + await registry.dispatch(makeClient(mockRequest), "iacm_provider", "list", { + page: 0, + size: 20, + search_term: "aws", + }); + await registry.dispatch(makeClient(mockRequest), "iacm_provider", "get", { id: "1" }); + await registry.dispatch(makeClient(mockRequest), "iacm_provider", "create", { + body: { type: "aws", description: "AWS provider" }, + }); + await registry.dispatch(makeClient(mockRequest), "iacm_provider", "update", { + id: "1", + body: { version: "1.0.0", protocol: ["5.0"], gpg_key_id: "key-1" }, + }); + await registry.dispatch(makeClient(mockRequest), "iacm_provider", "update", { + id: "1", + version: "1.0.0", + body: { protocol: ["5.0"], gpg_key_id: "key-1" }, + }); + + expect(mockRequest.mock.calls[0]![0]).toMatchObject({ + method: "GET", + path: "/iacm/api/providers", + params: { page: 1, limit: 20, searchTerm: "aws" }, + }); + expect(mockRequest.mock.calls[1]![0]).toMatchObject({ + method: "GET", + path: "/iacm/api/providers/1", + }); + expect(mockRequest.mock.calls[2]![0]).toMatchObject({ + method: "POST", + path: "/iacm/api/providers/aws", + body: { description: "AWS provider" }, + }); + expect(mockRequest.mock.calls[3]![0]).toMatchObject({ + method: "POST", + path: "/iacm/api/providers/1/version", + body: { version: "1.0.0", protocol: ["5.0"], gpg_key_id: "key-1" }, + }); + expect(mockRequest.mock.calls[4]![0]).toMatchObject({ + method: "PUT", + path: "/iacm/api/providers/1/version/1.0.0", + body: { protocol: ["5.0"], gpg_key_id: "key-1" }, + }); + }); }); diff --git a/tests/tools/tool-handlers.test.ts b/tests/tools/tool-handlers.test.ts index 430c1daf..36416eb9 100644 --- a/tests/tools/tool-handlers.test.ts +++ b/tests/tools/tool-handlers.test.ts @@ -1126,6 +1126,63 @@ describe("iacm_module create/update via MCP tools", () => { }); }); +describe("iacm_provider create/update via MCP tools", () => { + it("harness_create maps body.type to the provider path segment", async () => { + const created = { id: "1" }; + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "iacm" })); + const mockRequest = vi.fn().mockResolvedValue(created); + const client = makeClient(mockRequest); + const server = makeMcpServer("accept"); + const { registerCreateTool } = await import("../../src/tools/harness-create.js"); + registerCreateTool(server, registry, client, makeConfig({ HARNESS_TOOLSETS: "iacm" })); + + const result = await server.call("harness_create", { + resource_type: "iacm_provider", + body: { + type: "aws", + description: "AWS provider", + }, + }); + + expect(result.isError).toBeUndefined(); + expect(parseResult(result)).toEqual(created); + const callArgs = mockRequest.mock.calls[0]![0] as { method: string; path: string; body: unknown }; + expect(callArgs.method).toBe("POST"); + expect(callArgs.path).toBe("/iacm/api/providers/aws"); + expect(callArgs.body).toEqual({ description: "AWS provider" }); + }); + + it("harness_update creates a provider version when params.version is omitted", async () => { + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "iacm" })); + const mockRequest = vi.fn().mockResolvedValue({ status: "SUCCESS", message: "No content" }); + const client = makeClient(mockRequest); + const server = makeMcpServer("accept"); + const { registerUpdateTool } = await import("../../src/tools/harness-update.js"); + registerUpdateTool(server, registry, client, makeConfig({ HARNESS_TOOLSETS: "iacm" })); + + const result = await server.call("harness_update", { + resource_type: "iacm_provider", + resource_id: "1", + body: { + version: "1.0.0", + protocol: ["5.0"], + gpg_key_id: "key-1", + }, + }); + + expect(result.isError).toBeUndefined(); + expect(parseResult(result)).toEqual({ status: "SUCCESS", message: "No content" }); + const callArgs = mockRequest.mock.calls[0]![0] as { method: string; path: string; body: unknown }; + expect(callArgs.method).toBe("POST"); + expect(callArgs.path).toBe("/iacm/api/providers/1/version"); + expect(callArgs.body).toEqual({ + version: "1.0.0", + protocol: ["5.0"], + gpg_key_id: "key-1", + }); + }); +}); + describe("harness_update", () => { let server: ReturnType; let registry: Registry;