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
49 changes: 46 additions & 3 deletions src/tools/harness-execute.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as z from "zod/v4";
import { parse as parseYaml } from "yaml";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Registry } from "../registry/index.js";
import type { HarnessClient } from "../client/harness-client.js";
Expand All @@ -12,7 +12,7 @@ import { applyUrlDefaults } from "../utils/url-parser.js";
import { asRecord, asString, coerceRecord } from "../utils/type-guards.js";
import { isFlatKeyValueInputs, isResolvableInputs, flattenInputs, resolveRuntimeInputs, resolveRuntimeInputsWithBaseYaml, type ResolutionResult } from "../utils/runtime-input-resolver.js";
import { applyInputExpansions } from "../utils/input-expander.js";
import { materializeInputSetsToRuntimeYaml } from "../utils/materialize-input-sets.js";
import { materializeInputSetsToRuntimeYaml, mergeRuntimePipelineFragments } from "../utils/materialize-input-sets.js";
import { resourceScopeSchema, resourceTypeSchema } from "./input-schemas.js";
import { pollExecutionToTerminal, FAILURE_STATUSES, AbortError } from "../utils/poll-execution.js";
import { sendProgress } from "../utils/progress.js";
Expand All @@ -30,6 +30,31 @@ function hasNoInlineRuntimeInputs(inputs: unknown): boolean {
return !!record && Object.keys(record).length === 0;
}

/**
* True when `inputs` is a full pipeline document (`{ pipeline: ... }`) rather
* than a flat key/value override map. These are merged fragment-wise with a
* materialized input set (see the pipeline run path) instead of being resolved
* against the runtime input template.
*/
function isFullPipelineInputs(inputs: unknown): inputs is Record<string, unknown> {
const record = asRecord(inputs);
return !!record && "pipeline" in record;
}

/**
* Extract the inner `pipeline` fragment from a pipeline-execute runtime
* document, accepting either a YAML string or an already-parsed object. The
* top-level `pipeline` key is unwrapped when present; otherwise the whole
* document is treated as the fragment. Used to merge a caller-supplied full
* pipeline YAML with a materialized input set.
*/
function extractPipelineFragment(yamlOrObj: unknown): Record<string, unknown> {
const doc = typeof yamlOrObj === "string" ? parseYaml(yamlOrObj) : yamlOrObj;
const root = asRecord(doc);
if (!root) return {};
return asRecord(root.pipeline) ?? root;
}

/**
* Map `resource_id` onto the execute action's target identifier field, in
* place. Mirrors the remap performed inline in the dispatch path so that
Expand Down Expand Up @@ -276,7 +301,10 @@ export function registerExecuteTool(server: McpServer, registry: Registry, clien
resourceType === "pipeline" &&
args.action === "run" &&
hasInputSets &&
(hasNoInlineRuntimeInputs(args.inputs) || isResolvableInputs(args.inputs))
(hasNoInlineRuntimeInputs(args.inputs) ||
isResolvableInputs(args.inputs) ||
isFullPipelineInputs(args.inputs) ||
typeof args.inputs === "string")
) {
const pipelineId = asString(input.pipeline_id) ?? resourceId;
const orgId = asString(input.org_id) || registry.orgId;
Expand Down Expand Up @@ -307,6 +335,21 @@ export function registerExecuteTool(server: McpServer, registry: Registry, clien
input.inputs = materializedInputSetYaml;
delete input.input_set_ids;
materializedInputSets = true;
} else if (
materializedInputSetYaml &&
(isFullPipelineInputs(args.inputs) || typeof args.inputs === "string")
) {
// Caller supplied a full pipeline document (YAML string or object)
// alongside input_set_ids. Merge the input set as the base and the
// caller's document as the override (caller wins per variable /
// stage). Without this the input set would be dropped to the
// `inputSetIdentifiers` query param, which pipeline execute ignores.
const base = extractPipelineFragment(materializedInputSetYaml);
const override = extractPipelineFragment(args.inputs);
const merged = mergeRuntimePipelineFragments(base, override);
input.inputs = stringifyYaml({ pipeline: merged });
delete input.input_set_ids;
materializedInputSets = true;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
Expand Down
35 changes: 35 additions & 0 deletions tests/coding-standards/agents-md-consistency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Keep AGENTS.md aligned with enforced architecture constants.
* Prevents drift (e.g. reverting to "10 tools" or Zod v3) without updating tests.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { ALLOWED_MCP_TOOLS } from "./allowed-tools.js";

const REPO_ROOT = join(import.meta.dirname, "../..");
const AGENTS_PATH = join(REPO_ROOT, "AGENTS.md");

describe("Coding standards — AGENTS.md consistency", () => {
const content = readFileSync(AGENTS_PATH, "utf8");

it("AGENTS.md documents 11 consolidated MCP tools including harness_schema", () => {
expect(content).toMatch(/11 consolidated tools/);
for (const tool of ALLOWED_MCP_TOOLS) {
expect(content, `missing ${tool} in AGENTS.md`).toContain(tool);
}
});

it("AGENTS.md references Zod v4 (not v3)", () => {
expect(content).toMatch(/Zod v4/);
expect(content).not.toMatch(/\bZod v3\b/);
});

it("AGENTS.md documents pnpm standards:check guardrails", () => {
expect(content).toContain("pnpm standards:check");
});

it("AGENTS.md does not document 10 consolidated tools (stale architecture)", () => {
expect(content).not.toMatch(/\b10 consolidated tool/);
});
});
98 changes: 98 additions & 0 deletions tests/coding-standards/allowed-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Single source of truth for the 11 consolidated MCP tool handlers.
* Imported by coding-standards tests and docs-consistency checks — keep in
* sync with docs/coding-standards.md and AGENTS.md.
*/
export const ALLOWED_MCP_TOOLS = [
"harness_list",
"harness_get",
"harness_create",
"harness_update",
"harness_delete",
"harness_execute",
"harness_diagnose",
"harness_search",
"harness_describe",
"harness_status",
"harness_schema",
] as const;

export type AllowedMcpTool = (typeof ALLOWED_MCP_TOOLS)[number];

/** Only these files may call server.registerTool(). */
export const ALLOWED_HARNESS_HANDLER_FILES = [
"src/tools/harness-list.ts",
"src/tools/harness-get.ts",
"src/tools/harness-create.ts",
"src/tools/harness-update.ts",
"src/tools/harness-delete.ts",
"src/tools/harness-execute.ts",
"src/tools/harness-diagnose.ts",
"src/tools/harness-search.ts",
"src/tools/harness-describe.ts",
"src/tools/harness-status.ts",
"src/tools/harness-schema.ts",
] as const;

/** Handlers that perform write/execute operations requiring elicitation. */
export const WRITE_HANDLER_FILES = [
"src/tools/harness-create.ts",
"src/tools/harness-update.ts",
"src/tools/harness-delete.ts",
"src/tools/harness-execute.ts",
] as const;

/** Handlers that call HarnessClient / registry dispatch against the live API. */
export const API_HANDLER_FILES = [
"src/tools/harness-list.ts",
"src/tools/harness-get.ts",
...WRITE_HANDLER_FILES,
"src/tools/harness-diagnose.ts",
"src/tools/harness-search.ts",
"src/tools/harness-status.ts",
] as const;

/** Local metadata / schema tools — no live API dispatch. */
export const LOCAL_ONLY_HANDLER_FILES = [
"src/tools/harness-describe.ts",
"src/tools/harness-schema.ts",
] as const;

/** All 11 harness handler files (API + local-only). */
export const ALL_HANDLER_FILES = [
...API_HANDLER_FILES,
...LOCAL_ONLY_HANDLER_FILES,
] as const;

/** Allowed root-level files under src/tools/ (handlers + shared schemas). */
export const ALLOWED_TOOLS_ROOT_FILES = new Set([
"harness-list.ts",
"harness-get.ts",
"harness-create.ts",
"harness-update.ts",
"harness-delete.ts",
"harness-execute.ts",
"harness-diagnose.ts",
"harness-search.ts",
"harness-describe.ts",
"harness-status.ts",
"harness-schema.ts",
"index.ts",
"input-schemas.ts",
"output-schemas.ts",
]);

/** registerAllTools() must wire exactly these register*Tool calls. */
export const EXPECTED_REGISTER_TOOL_CALLS = [
"List",
"Get",
"Create",
"Update",
"Delete",
"Execute",
"Diagnose",
"Search",
"Describe",
"Status",
"Schema",
] as const;
72 changes: 13 additions & 59 deletions tests/coding-standards/architecture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,44 +12,19 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { ALL_TOOLSET_NAMES } from "../../src/registry/index.js";
import type { ToolsetName } from "../../src/registry/types.js";
import {
ALLOWED_MCP_TOOLS,
ALLOWED_HARNESS_HANDLER_FILES,
EXPECTED_REGISTER_TOOL_CALLS,
WRITE_HANDLER_FILES,
} from "./allowed-tools.js";

const REPO_ROOT = join(import.meta.dirname, "../..");
const SRC = join(REPO_ROOT, "src");

/** The only MCP tools allowed in the server. */
const ALLOWED_MCP_TOOLS = new Set([
"harness_list",
"harness_get",
"harness_create",
"harness_update",
"harness_delete",
"harness_execute",
"harness_diagnose",
"harness_search",
"harness_describe",
"harness_status",
"harness_schema",
]);

/** Only these files may call server.registerTool(). */
const ALLOWED_REGISTER_TOOL_FILES = new Set([
"src/tools/harness-list.ts",
"src/tools/harness-get.ts",
"src/tools/harness-create.ts",
"src/tools/harness-update.ts",
"src/tools/harness-delete.ts",
"src/tools/harness-execute.ts",
"src/tools/harness-diagnose.ts",
"src/tools/harness-search.ts",
"src/tools/harness-describe.ts",
"src/tools/harness-status.ts",
"src/tools/harness-schema.ts",
]);

/** Only these harness-*.ts handler files may exist under src/tools/. */
const ALLOWED_HARNESS_HANDLER_FILES = new Set([
...ALLOWED_REGISTER_TOOL_FILES,
]);
const ALLOWED_MCP_TOOL_SET = new Set<string>(ALLOWED_MCP_TOOLS);
const ALLOWED_REGISTER_TOOL_FILES = new Set<string>(ALLOWED_HARNESS_HANDLER_FILES);
const ALLOWED_HARNESS_HANDLER_FILE_SET = ALLOWED_REGISTER_TOOL_FILES;

/** Toolset helper modules — not required to export a ToolsetDefinition. */
const TOOLSET_HELPER_FILES = new Set([
Expand All @@ -69,13 +44,6 @@ const ALLOWED_INLINE_EXTRACTOR_COUNTS: Record<string, number> = {
"src/registry/toolsets/sto.ts": 3,
};

const WRITE_TOOL_FILES = [
"src/tools/harness-create.ts",
"src/tools/harness-update.ts",
"src/tools/harness-delete.ts",
"src/tools/harness-execute.ts",
] as const;

/** Forbidden import patterns in toolset definition files. */
const FORBIDDEN_TOOLSET_IMPORTS: Array<{ pattern: RegExp; reason: string }> = [
{ pattern: /from\s+["'][^"']*harness-client/, reason: "HarnessClient import" },
Expand Down Expand Up @@ -170,21 +138,7 @@ describe("Coding standards — MCP tool handlers", () => {
const indexPath = join(SRC, "tools/index.ts");
const content = readFileSync(indexPath, "utf8");
const registerCalls = [...content.matchAll(/\bregister(\w+)Tool\s*\(/g)].map((m) => m[1]!);
const expected = [
"List",
"Get",
"Create",
"Update",
"Delete",
"Execute",
"Diagnose",
"Search",
"Describe",
"Status",
"Schema",
];

expect([...registerCalls].sort()).toEqual([...expected].sort());
expect([...registerCalls].sort()).toEqual([...EXPECTED_REGISTER_TOOL_CALLS].sort());
expect(registerCalls).toHaveLength(11);
});

Expand All @@ -198,7 +152,7 @@ describe("Coding standards — MCP tool handlers", () => {
}
}

expect([...registered].sort()).toEqual([...ALLOWED_MCP_TOOLS].sort());
expect([...registered].sort()).toEqual([...ALLOWED_MCP_TOOL_SET].sort());
});

it("only allows registerTool() in the 11 harness handler files", () => {
Expand All @@ -225,7 +179,7 @@ describe("Coding standards — MCP tool handlers", () => {
.filter((f) => f.startsWith("harness-") && f.endsWith(".ts"))
.map((f) => `src/tools/${f}`);

const unexpected = harnessFiles.filter((f) => !ALLOWED_HARNESS_HANDLER_FILES.has(f));
const unexpected = harnessFiles.filter((f) => !ALLOWED_HARNESS_HANDLER_FILE_SET.has(f));
expect(unexpected, `New harness handler files found: ${unexpected.join(", ")}`).toEqual([]);
});
});
Expand Down Expand Up @@ -498,7 +452,7 @@ describe("Coding standards — tool handler contracts", () => {
it("write tool handlers declare confirm param and use elicitation", () => {
const violations: string[] = [];

for (const file of WRITE_TOOL_FILES) {
for (const file of WRITE_HANDLER_FILES) {
const content = readFileSync(join(REPO_ROOT, file), "utf8");
if (!/confirm:\s*z\.boolean\(/.test(content)) {
violations.push(`${file}: missing confirm z.boolean() input param`);
Expand Down
21 changes: 6 additions & 15 deletions tests/coding-standards/docs-consistency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,17 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { ALLOWED_MCP_TOOLS } from "./allowed-tools.js";

const REPO_ROOT = join(import.meta.dirname, "../..");
const STANDARDS_PATH = join(REPO_ROOT, "docs/coding-standards.md");

const REQUIRED_TOOLS = [
"harness_list",
"harness_get",
"harness_create",
"harness_update",
"harness_delete",
"harness_execute",
"harness_diagnose",
"harness_search",
"harness_describe",
"harness_status",
"harness_schema",
];

describe("Coding standards — documentation consistency", () => {
const content = readFileSync(STANDARDS_PATH, "utf8");

it("docs/coding-standards.md documents 11 consolidated MCP tools including harness_schema", () => {
expect(content).toMatch(/11 consolidated tool handlers/);
for (const tool of REQUIRED_TOOLS) {
for (const tool of ALLOWED_MCP_TOOLS) {
expect(content, `missing ${tool} in docs/coding-standards.md`).toContain(tool);
}
});
Expand All @@ -45,4 +32,8 @@ describe("Coding standards — documentation consistency", () => {
it("docs/coding-standards.md forbids new harness-*.ts handler files", () => {
expect(content).toMatch(/Do NOT add new `harness-\*\.ts` handler files/);
});

it("docs/coding-standards.md does not document 10 consolidated tools (stale architecture)", () => {
expect(content).not.toMatch(/\b10 consolidated tool/);
});
});
Loading
Loading