From 7aa3b914c2892135734f554788efeff2cc2833c9 Mon Sep 17 00:00:00 2001 From: Rohan Gupta Date: Tue, 30 Jun 2026 18:31:54 -0700 Subject: [PATCH 1/3] chore: release 3.2.5 Bump version to 3.2.5 across package.json, root and directory bundle manifests, and the release-metadata version contract test. Co-Authored-By: Claude Opus 4.8 AI-Session-Id: cc0246c6-f5e1-40f2-bf1a-6d8f3c6ba701 AI-Tool: claude-code AI-Model: unknown --- manifest.json | 2 +- mcp-directory/manifest.json | 2 +- package.json | 2 +- tests/release-metadata.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/manifest.json b/manifest.json index 567fe4b2..d5516b2f 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.3", "name": "harness-mcp-server", "display_name": "Harness", - "version": "3.2.4", + "version": "3.2.5", "description": "Connect AI agents to Harness for CI/CD, code repositories, pull requests, services, environments, and feature flags.", "long_description": "The Harness MCP Server gives MCP-compatible clients access to Harness platform workflows through consolidated tools, resources, and prompts. It supports local stdio usage and can be bundled as a Node-based MCPB extension.", "author": { diff --git a/mcp-directory/manifest.json b/mcp-directory/manifest.json index 3aeedef0..b176de11 100644 --- a/mcp-directory/manifest.json +++ b/mcp-directory/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.3", "name": "harness-mcp-server", "display_name": "Harness", - "version": "3.2.4", + "version": "3.2.5", "description": "Connect AI agents to Harness for CI/CD, code repositories, pull requests, services, environments, and feature flags.", "long_description": "The Harness MCP Server gives MCP-compatible clients access to Harness platform workflows through consolidated tools, resources, and prompts. It supports local stdio usage and can be bundled as a Node-based MCPB extension.", "author": { diff --git a/package.json b/package.json index a0a1eb38..c4ec82fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "harness-mcp-v2", - "version": "3.2.4", + "version": "3.2.5", "description": "Give AI agents full access to the Harness.io platform — manage pipelines, deployments, cloud costs, chaos engineering, feature flags, SEI, and 125+ resource types through 11 MCP tools", "type": "module", "main": "build/index.js", diff --git a/tests/release-metadata.test.ts b/tests/release-metadata.test.ts index addaa733..adab9b71 100644 --- a/tests/release-metadata.test.ts +++ b/tests/release-metadata.test.ts @@ -24,7 +24,7 @@ describe("release metadata", () => { const rootManifest = readJson("manifest.json"); const directoryManifest = readJson("mcp-directory/manifest.json"); - expect(packageJson.version).toBe("3.2.4"); + expect(packageJson.version).toBe("3.2.5"); expect(rootManifest.version).toBe(packageJson.version); expect(directoryManifest.version).toBe(packageJson.version); }); From 8286900aa1fcfb2073b23654af12c5f036ac085f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 01:35:40 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20enforce=20toolset=20purity=20?= =?UTF-8?q?=E2=80=94=20remove=20createLogger=20from=20scs=20toolset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove createLogger from scs_remediation_pr preflight (silent fail-open on transient errors) - Ban createLogger imports and usage in toolset files via architecture guardrails - Document createLogger ban and remote-provider fetch exception in coding-standards.md Co-authored-by: Rohan Gupta --- docs/coding-standards.md | 4 +++- src/registry/toolsets/scs.ts | 24 ++++++--------------- tests/coding-standards/architecture.test.ts | 10 ++++++--- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/docs/coding-standards.md b/docs/coding-standards.md index 6b621262..a4c24841 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -46,6 +46,7 @@ Files in `src/registry/toolsets/*.ts` must export a `ToolsetDefinition` object ( They must NOT: - Import `HarnessClient`, `McpServer`, or `Registry` +- Import `createLogger` or write logs (`console.*`) — toolsets are pure data; logging belongs in handlers and the registry - Make HTTP calls directly (use `preflight` hooks with structural client interfaces from `PreflightContext` when needed) - Contain business logic beyond field mapping - Use `console.log()` (or any stdout writes) @@ -144,6 +145,7 @@ All Zod schemas in tool handlers must: | `src/client/harness-client.ts` | Core HTTP transport | | `src/utils/log-resolver.ts` | Pre-signed CDN/S3 blob URLs must not receive API auth headers (would invalidate signatures) | | `src/audit/sinks/webhook.ts` | Best-effort POST to a user-configured external audit webhook URL | +| `src/search/remote-provider.ts` | Optional remote search index service (separate from Harness API auth) | ### 11. File Organization Rules @@ -260,7 +262,7 @@ Before every commit, verify: - [ ] **No `console.log()`** — only `createLogger()` for stderr output - [ ] **No direct `fetch()` calls** — all Harness API HTTP goes through `HarnessClient` (exceptions: log blob CDN URLs, audit webhooks — see rule 10) - [ ] **No new `harness-*.ts` handler files** — the 11 handlers are fixed -- [ ] **Toolset files are pure data** — no imports of `HarnessClient`, `McpServer`, or `Registry` +- [ ] **Toolset files are pure data** — no imports of `HarnessClient`, `McpServer`, `Registry`, or `createLogger` - [ ] **All Zod params have `.describe()` after `.optional()`/`.default()`** — Zod 4 chaining order matters for MCP - [ ] **Response extractors are from `extractors.ts`** — reuse shared extractors - [ ] **Scope is declared correctly** — `"project"`, `"org"`, or `"account"` diff --git a/src/registry/toolsets/scs.ts b/src/registry/toolsets/scs.ts index 022b308f..eb30d0f4 100644 --- a/src/registry/toolsets/scs.ts +++ b/src/registry/toolsets/scs.ts @@ -1,9 +1,6 @@ import type { ToolsetDefinition, FilterFieldSpec, ParamsSchema } from "../types.js"; import { scsCleanExtract, scsListExtract } from "../extractors.js"; import { HarnessApiError } from "../../utils/errors.js"; -import { createLogger } from "../../utils/logger.js"; - -const log = createLogger("scs-toolset"); function filterFieldsToParamsSchema(fields: FilterFieldSpec[]): ParamsSchema { return { @@ -961,10 +958,10 @@ export const scsToolset: ToolsetDefinition = { * • HTTP 4xx ⇒ fail CLOSED (throw). A client-side error (bad args, * auth, scope) indicates a real problem; proceeding * would silently bypass the duplicate invariant. - * • HTTP 5xx / network / non-HarnessApiError ⇒ fail OPEN with a - * structured warn log. The check is best-effort and - * transient upstream issues must not permanently block - * legitimate remediation creation. + * • HTTP 5xx / network / non-HarnessApiError ⇒ fail OPEN silently. + * The check is best-effort and transient upstream + * issues must not permanently block legitimate + * remediation creation. * * SCOPE: the preflight's inner list call inherits `org_id` / * `project_id` from the outer create input; it does NOT fall back @@ -1041,9 +1038,9 @@ export const scsToolset: ToolsetDefinition = { // 4xx → fail CLOSED. A client-side error (bad args, auth, scope) // indicates a real problem — creating through it would // silently bypass the duplicate invariant. - // 5xx / network / timeout → fail OPEN with a logged warning. - // The check is best-effort; transient upstream issues - // should not permanently block remediation creation. + // 5xx / network / timeout → fail OPEN silently. The check is + // best-effort; transient upstream issues should not + // permanently block remediation creation. const status = err instanceof HarnessApiError ? err.statusCode : undefined; if (status !== undefined && status >= 400 && status < 500) { throw new Error( @@ -1052,13 +1049,6 @@ export const scsToolset: ToolsetDefinition = { + `Resolve the list error (verify artifact_id and scope) before retrying create.`, ); } - log.warn("scs_remediation_pr preflight skipped (transient error)", { - artifactId, - purl, - page, - status, - error: err instanceof Error ? err.message : String(err), - }); return; } const batch = pickItems(raw); diff --git a/tests/coding-standards/architecture.test.ts b/tests/coding-standards/architecture.test.ts index da7627cb..89434c5e 100644 --- a/tests/coding-standards/architecture.test.ts +++ b/tests/coding-standards/architecture.test.ts @@ -81,6 +81,7 @@ const FORBIDDEN_TOOLSET_IMPORTS: Array<{ pattern: RegExp; reason: string }> = [ { pattern: /from\s+["'][^"']*harness-client/, reason: "HarnessClient import" }, { pattern: /from\s+["']@modelcontextprotocol\/sdk/, reason: "McpServer/MCP SDK import" }, { pattern: /from\s+["'][^"']*\/registry\/index/, reason: "Registry import" }, + { pattern: /from\s+["'][^"']*\/utils\/logger/, reason: "createLogger import (toolsets are pure data)" }, ]; /** Files allowed to call the global fetch() API (documented exceptions). */ @@ -209,7 +210,7 @@ describe("Coding standards — logging and HTTP", () => { expect(violations, `console.log() found in:\n${violations.join("\n")}`).toEqual([]); }); - it("toolset files do not use console.* (use createLogger in handlers, not toolsets)", () => { + it("toolset files do not use console.* or createLogger (logging belongs in handlers/registry)", () => { const violations: string[] = []; const toolsetDir = join(SRC, "registry/toolsets"); @@ -219,11 +220,14 @@ describe("Coding standards — logging and HTTP", () => { const content = readFileSync(file, "utf8"); if (/\bconsole\.(log|error|warn|info|debug)\s*\(/.test(content)) { - violations.push(fileRel); + violations.push(`${fileRel}: console.* usage`); + } + if (/\bcreateLogger\s*\(/.test(content)) { + violations.push(`${fileRel}: createLogger() usage`); } } - expect(violations, `console.* found in toolsets:\n${violations.join("\n")}`).toEqual([]); + expect(violations, `Logging found in toolsets:\n${violations.join("\n")}`).toEqual([]); }); it("does not use raw fetch() in tool handlers or toolset definitions", () => { From 41cb9fa50db88d311c12265f5de907cec2937970 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 02:59:56 +0000 Subject: [PATCH 3/3] fix: route tool handler API calls through registry.dispatch - harness-execute fetchInputSetHint: use input_set list dispatch instead of client.request - diagnose/pipeline diagnoseChildPipeline: use execution get dispatch for child runs - Add architecture guardrail banning client.request in tool handlers (entity-schema/live exempt) - Update chained-pipeline diagnose test to mock registry.dispatch for child execution - Document client.request anti-pattern in coding-standards.md Co-authored-by: Rohan Gupta --- docs/coding-standards.md | 1 + src/tools/diagnose/pipeline.ts | 23 +++++++-------- src/tools/harness-execute.ts | 20 ++++++------- tests/coding-standards/architecture.test.ts | 25 +++++++++++++++++ tests/tools/diagnose/pipeline.test.ts | 31 +++++++++++++-------- 5 files changed, 64 insertions(+), 36 deletions(-) diff --git a/docs/coding-standards.md b/docs/coding-standards.md index a4c24841..5c81c7e6 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -285,6 +285,7 @@ Before every commit, verify: | Import `HarnessClient` in a toolset file | Toolsets are pure data — they don't execute HTTP directly | | Use `console.log()` | Corrupts stdio JSON-RPC transport | | Make raw `fetch()` calls | Bypasses auth, retry, rate limiting | +| Call `client.request()` from tool handlers | Bypasses registry dispatch — use `registry.dispatch()` (exception: `entity-schema/live.ts`) | | Create a new HTTP client instance | Singleton pattern — one client, injected everywhere | | Hardcode `accountIdentifier` | Client injects it automatically | | Return raw API responses | Always use response extractors to normalize | diff --git a/src/tools/diagnose/pipeline.ts b/src/tools/diagnose/pipeline.ts index f065a759..4de71e69 100644 --- a/src/tools/diagnose/pipeline.ts +++ b/src/tools/diagnose/pipeline.ts @@ -1,5 +1,6 @@ import type { DiagnoseHandler, DiagnoseContext } from "./types.js"; import type { HarnessClient } from "../../client/harness-client.js"; +import type { Registry } from "../../registry/index.js"; import type { Config } from "../../config.js"; import { createLogger } from "../../utils/logger.js"; import { sendProgress } from "../../utils/progress.js"; @@ -283,22 +284,18 @@ function findChildPipelineRef( async function diagnoseChildPipeline( client: HarnessClient, + registry: Registry, child: { executionId: string; orgId: string; projectId: string }, signal?: AbortSignal, ): Promise { try { - const response = await client.request>({ - method: "GET", - path: `/pipeline/api/pipelines/execution/v2/${child.executionId}`, - params: { - orgIdentifier: child.orgId, - projectIdentifier: child.projectId, - renderFullBottomGraph: "true", - }, - signal, - }); - const responseRec = asRecord(response) ?? {}; - const data = asRecord(responseRec.data) ?? responseRec; + const execution = await registry.dispatch(client, "execution", "get", { + execution_id: child.executionId, + org_id: child.orgId, + project_id: child.projectId, + render_full_graph: true, + }, signal); + const data = asRecord(execution) ?? {}; const execGraph = asRecord(data.executionGraph); const graphNodeMap = asRecord(execGraph?.nodeMap) as Record | undefined; if (graphNodeMap) return findFailedNodes(graphNodeMap); @@ -536,7 +533,7 @@ export const pipelineHandler: DiagnoseHandler = { if (result.childRef) { log.info("Detected chained pipeline failure, diagnosing child", result.childRef); - const childFailedNodes = await diagnoseChildPipeline(client, result.childRef, signal); + const childFailedNodes = await diagnoseChildPipeline(client, registry, result.childRef, signal); if (childFailedNodes.length > 0) { const childEntry = (f: FailedNodeDetail) => { const e: Record = { diff --git a/src/tools/harness-execute.ts b/src/tools/harness-execute.ts index 3ea4664a..c6c8a5be 100644 --- a/src/tools/harness-execute.ts +++ b/src/tools/harness-execute.ts @@ -613,18 +613,14 @@ async function fetchInputSetHint( registry: Registry, ): Promise { try { - const raw = await client.request({ - method: "GET", - path: "/pipeline/api/inputSets", - params: { - pipelineIdentifier: pipelineId, - orgIdentifier: String(input.org_id || registry.orgId), - projectIdentifier: String(input.project_id || registry.projectId), - size: "5", - }, + const result = await registry.dispatch(client, "input_set", "list", { + pipeline_id: pipelineId, + org_id: input.org_id ?? registry.orgId, + project_id: input.project_id ?? registry.projectId, + size: 5, }); - const data = asRecord(asRecord(raw)?.data); - const content = data?.content; + const list = asRecord(result); + const content = list?.items; if (!Array.isArray(content) || content.length === 0) return null; const ids = content @@ -632,7 +628,7 @@ async function fetchInputSetHint( .filter(Boolean); if (ids.length === 0) return null; - const total = typeof data?.totalElements === "number" ? data.totalElements : ids.length; + const total = typeof list?.total === "number" ? list.total : ids.length; return `Available input sets for this pipeline (${total} total): [${ids.join(", ")}]. Use input_set_ids=[""] to apply one.`; } catch { return null; diff --git a/tests/coding-standards/architecture.test.ts b/tests/coding-standards/architecture.test.ts index 89434c5e..4ea3ca87 100644 --- a/tests/coding-standards/architecture.test.ts +++ b/tests/coding-standards/architecture.test.ts @@ -95,6 +95,11 @@ const ALLOWED_GLOBAL_FETCH_FILES = new Set([ /** Only this file may instantiate HarnessClient in production src/. */ const ALLOWED_HARNESS_CLIENT_FILES = new Set(["src/index.ts"]); +/** Tool handler files allowed to call client.request() directly (documented exceptions). */ +const ALLOWED_CLIENT_REQUEST_TOOL_FILES = new Set([ + "src/tools/entity-schema/live.ts", +]); + /** Files that must import Zod via the v4 subpath — computed after walkTsFiles is defined. */ function zodV4RequiredFiles(): string[] { return [join(SRC, "config.ts"), ...walkTsFiles(join(SRC, "tools"))]; @@ -285,6 +290,26 @@ describe("Coding standards — logging and HTTP", () => { `HarnessClient must only be constructed in src/index.ts:\n${violations.join("\n")}`, ).toEqual([]); }); + + it("tool handlers route API calls through registry.dispatch (not client.request)", () => { + const violations: string[] = []; + const toolsDir = join(SRC, "tools"); + + for (const file of walkTsFiles(toolsDir)) { + const content = readFileSync(file, "utf8"); + if (!/\bclient\.request\s*[<(]/.test(content)) continue; + + const fileRel = rel(file); + if (!ALLOWED_CLIENT_REQUEST_TOOL_FILES.has(fileRel)) { + violations.push(fileRel); + } + } + + expect( + violations, + `client.request() bypasses registry dispatch — use registry.dispatch() (allowed: ${[...ALLOWED_CLIENT_REQUEST_TOOL_FILES].join(", ")}):\n${violations.join("\n")}`, + ).toEqual([]); + }); }); describe("Coding standards — toolset purity", () => { diff --git a/tests/tools/diagnose/pipeline.test.ts b/tests/tools/diagnose/pipeline.test.ts index 93bbed98..569d52a0 100644 --- a/tests/tools/diagnose/pipeline.test.ts +++ b/tests/tools/diagnose/pipeline.test.ts @@ -206,7 +206,7 @@ describe("pipelineHandler", () => { }); it("diagnoses chained pipeline failure via child execution", async () => { - const exec = makeExecution({ + const parentExec = makeExecution({ status: "Failed", stages: [{ id: "chain", name: "Chain Stage", status: "Failed" }], nodeMapEntries: { @@ -240,18 +240,27 @@ describe("pipelineHandler", () => { }, }; - const clientMock = { - request: vi.fn().mockResolvedValue({ - data: { - executionGraph: { nodeMap: childNodeMap }, - pipelineExecutionSummary: { planExecutionId: "child-exec-001" }, - }, + const childExec = { + executionGraph: { nodeMap: childNodeMap }, + pipelineExecutionSummary: { planExecutionId: "child-exec-001" }, + }; + + const registry = { + dispatch: vi.fn(async (_c: unknown, resourceType: string, op: string, input: Record) => { + if (resourceType === "execution" && op === "get") { + return input.execution_id === "child-exec-001" ? childExec : parentExec; + } + if (resourceType === "execution" && op === "list") { + return { items: [{ planExecutionId: input.execution_id ?? "exec-001" }] }; + } + if (resourceType === "pipeline" && op === "get") return { yaml: "pipeline: {}" }; + throw new Error(`Unmocked: ${resourceType}.${op}`); }), - account: "test-account", - } as unknown as HarnessClient; + dispatchExecute: vi.fn(), + getAccountId: () => "test-account", + } as unknown as Registry; - const registry = makePipelineRegistry(exec); - const ctx = makeContext({ input: { execution_id: "exec-001" }, registry, client: clientMock, args: { summary: true } }); + const ctx = makeContext({ input: { execution_id: "exec-001" }, registry, args: { summary: true } }); const result = await pipelineHandler.diagnose(ctx); const execution = result.execution as Record;