Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/coding-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"`
Expand All @@ -283,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 |
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion mcp-directory/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
24 changes: 7 additions & 17 deletions src/registry/toolsets/scs.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand Down
23 changes: 10 additions & 13 deletions src/tools/diagnose/pipeline.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -283,22 +284,18 @@ function findChildPipelineRef(

async function diagnoseChildPipeline(
client: HarnessClient,
registry: Registry,
child: { executionId: string; orgId: string; projectId: string },
signal?: AbortSignal,
): Promise<FailedNodeDetail[]> {
try {
const response = await client.request<Record<string, unknown>>({
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<string, ExecGraphNode> | undefined;
if (graphNodeMap) return findFailedNodes(graphNodeMap);
Expand Down Expand Up @@ -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<string, unknown> = {
Expand Down
20 changes: 8 additions & 12 deletions src/tools/harness-execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,26 +613,22 @@ async function fetchInputSetHint(
registry: Registry,
): Promise<string | null> {
try {
const raw = await client.request<unknown>({
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
.map((item: unknown) => asString(asRecord(item)?.identifier))
.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=["<id>"] to apply one.`;
} catch {
return null;
Expand Down
35 changes: 32 additions & 3 deletions tests/coding-standards/architecture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -94,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"))];
Expand Down Expand Up @@ -209,7 +215,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");

Expand All @@ -219,11 +225,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", () => {
Expand Down Expand Up @@ -281,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", () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/release-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
31 changes: 20 additions & 11 deletions tests/tools/diagnose/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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<string, unknown>) => {
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<string, unknown>;
Expand Down