Skip to content
Merged
11 changes: 9 additions & 2 deletions src/data/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import agentPipeline from "./local/agent-pipeline.js";
type V0SchemaKey = "pipeline" | "template" | "trigger";
type V1SchemaKey = "pipeline_v1" | "template_v1" | "trigger_v1" | "inputSet_v1" | "overlayInputSet_v1" | "service_v1" | "infra_v1";
type LocalSchemaKey = "agent-pipeline";
type AllSchemaKeys = V0SchemaKey | V1SchemaKey | LocalSchemaKey;
export type AllSchemaKeys = V0SchemaKey | V1SchemaKey | LocalSchemaKey;

export const SCHEMAS: Record<AllSchemaKeys, Record<string, any>> = {
"pipeline": pipeline,
Expand All @@ -31,8 +31,15 @@ export const SCHEMAS: Record<AllSchemaKeys, Record<string, any>> = {
"agent-pipeline": agentPipeline,
};

export const VALID_SCHEMAS = Object.keys(SCHEMAS) as AllSchemaKeys[];
export const VALID_SCHEMAS: AllSchemaKeys[] = Object.keys(SCHEMAS) as AllSchemaKeys[];
export type SchemaName = AllSchemaKeys;

/** Metadata-wrapped schema entry. All registered schemas must provide description and group. */
export type SchemaEntry = {
schema: Record<string, any>;
description: string;
group: string;
};

export const V0_SCHEMA_KEYS: V0SchemaKey[] = ["pipeline", "template", "trigger"];
export const V1_SCHEMA_KEYS: V1SchemaKey[] = ["pipeline_v1", "template_v1", "trigger_v1", "inputSet_v1", "overlayInputSet_v1", "service_v1", "infra_v1"];
37 changes: 25 additions & 12 deletions src/resources/harness-schema.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,40 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { createLogger } from "../utils/logger.js";
import { SCHEMAS, VALID_SCHEMAS, type SchemaName } from "../data/schemas/index.js";
import { SCHEMAS, VALID_SCHEMAS, type SchemaName, type SchemaEntry } from "../data/schemas/index.js";

const log = createLogger("resource:harness-schema");

function isValidSchemaName(name: string): name is SchemaName {
return (VALID_SCHEMAS as readonly string[]).includes(name);
export function isValidSchemaName(name: string, validNames: readonly string[] = VALID_SCHEMAS): name is SchemaName {
return validNames.includes(name);
}

export function registerHarnessSchemaResource(server: McpServer): void {
export function registerHarnessSchemaResource(
server: McpServer,
additionalSchemas?: Record<string, SchemaEntry>,
): void {
if (additionalSchemas) {
for (const key of Object.keys(additionalSchemas)) {
if (key in SCHEMAS) {
throw new Error(`additionalSchemas key '${key}' conflicts with a built-in schema name`);
}
}
}
const allSchemas: Record<string, Record<string, any>> = additionalSchemas
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This merge silently allows an extension schema to shadow a built-in name after only logging a warning. That means callers can replace canonical contracts like pipeline or trigger in both schema:///... and harness_schema(...), which is the opposite of the repo's fail-loud guidance. Duplicate keys should throw instead of overriding built-ins.

? { ...SCHEMAS, ...Object.fromEntries(Object.entries(additionalSchemas).map(([k, v]) => [k, v.schema])) }
: { ...SCHEMAS };
const allSchemaNames = Object.keys(allSchemas);

const template = new ResourceTemplate("schema:///{schemaName}", {
list: async () => ({
resources: VALID_SCHEMAS.map((name) => ({
resources: allSchemaNames.map((name) => ({
uri: `schema:///${name}`,
name: `${name} schema`,
})),
}),
complete: {
schemaName: (value) =>
VALID_SCHEMAS.filter((s) => s.startsWith(value)),
allSchemaNames.filter((s) => s.startsWith(value)),
},
});

Expand All @@ -28,19 +43,19 @@ export function registerHarnessSchemaResource(server: McpServer): void {
template,
{
title: "Harness Schema",
description: `Harness JSON Schema definitions. Valid schema names: ${VALID_SCHEMAS.join(", ")}. Use these to understand the required body format for harness_create.`,
description: `Harness JSON Schema definitions. Valid schema names: ${allSchemaNames.join(", ")}. Use these to understand the required body format for harness_create.`,
mimeType: "application/schema+json",
},
async (uri) => {
const schemaName = uri.pathname.replace(/^\/+/, "");

if (!isValidSchemaName(schemaName)) {
if (!isValidSchemaName(schemaName, allSchemaNames)) {
throw new Error(
`Unknown schema '${schemaName}'. Valid schemas: ${VALID_SCHEMAS.join(", ")}`,
`Unknown schema '${schemaName}'. Valid schemas: ${allSchemaNames.join(", ")}`,
);
}

const schema = SCHEMAS[schemaName];
const schema = allSchemas[schemaName];

return {
contents: [
Expand All @@ -55,5 +70,3 @@ export function registerHarnessSchemaResource(server: McpServer): void {
);
}

// Exported for testing
export { isValidSchemaName };
5 changes: 3 additions & 2 deletions src/resources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import type { Config } from "../config.js";
import { registerPipelineYamlResource } from "./pipeline-yaml.js";
import { registerExecutionSummaryResource } from "./execution-summary.js";
import { registerHarnessSchemaResource } from "./harness-schema.js";
import type { SchemaEntry } from "../data/schemas/index.js";

export function registerAllResources(server: McpServer, registry: Registry, client: HarnessClient, config: Config): void {
export function registerAllResources(server: McpServer, registry: Registry, client: HarnessClient, config: Config, additionalSchemas?: Record<string, SchemaEntry>): void {
registerPipelineYamlResource(server, registry, client, config);
registerExecutionSummaryResource(server, registry, client, config);
registerHarnessSchemaResource(server);
registerHarnessSchemaResource(server, additionalSchemas);
}
30 changes: 23 additions & 7 deletions src/tools/harness-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as z from "zod/v4";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { jsonResult, errorResult } from "../utils/response-formatter.js";
import { createLogger } from "../utils/logger.js";
import { SCHEMAS, VALID_SCHEMAS } from "../data/schemas/index.js";
import { SCHEMAS, VALID_SCHEMAS, type SchemaEntry } from "../data/schemas/index.js";
import { getExample, searchExamples, getExamplesForResource } from "../data/examples/index.js";

const log = createLogger("tool:harness-schema");
Expand Down Expand Up @@ -96,10 +96,13 @@ function navigateToPath(
*/
function getSummary(schema: Record<string, unknown>, resourceType: string): Record<string, unknown> {
const definitions = schema.definitions as Record<string, Record<string, unknown>> | undefined;
const sections = definitions ? Object.keys(definitions[resourceType] ?? {}) : [];

// Get the root resource definition
const rootDef = definitions?.[resourceType]?.[resourceType] as Record<string, unknown> | undefined;
// Harness-generated schemas nest the root definition under definitions[type][type].
// Plain JSON Schemas (extension schemas) place properties at the root level.
const harnessRootDef = definitions?.[resourceType]?.[resourceType] as Record<string, unknown> | undefined;
const rootDef = harnessRootDef ?? (schema.properties ? schema : undefined) as Record<string, unknown> | undefined;

const sections = definitions ? Object.keys(definitions[resourceType] ?? {}) : [];
const properties = rootDef?.properties as Record<string, unknown> | undefined;
const required = rootDef?.required as string[] | undefined;

Expand All @@ -124,8 +127,21 @@ function getSummary(schema: Record<string, unknown>, resourceType: string): Reco
};
}

export function registerSchemaTool(server: McpServer): void {
const availableSchemas = VALID_SCHEMAS;
export function registerSchemaTool(
server: McpServer,
additionalSchemas?: Record<string, SchemaEntry>,
): void {
if (additionalSchemas) {
for (const key of Object.keys(additionalSchemas)) {
if (key in SCHEMAS) {
throw new Error(`additionalSchemas key '${key}' conflicts with a built-in schema name`);
}
}
}
const allSchemas: Record<string, Record<string, any>> = additionalSchemas
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This accepts arbitrary additionalSchemas, but the rest of the tool still assumes the generated Harness layout under definitions[resourceType][resourceType] when building the summary/path responses. For a plain JSON Schema like the one added in tests/tools/harness-schema-tool.test.ts, harness_schema(resource_type='DashboardContract') will silently report fields: [] and no sections instead of failing loudly or normalizing the schema. Please validate or normalize extension schemas up front.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

additionalSchemas is typed as arbitrary JSON objects, but the rest of this tool still assumes the generated Harness layout under definitions[resourceType][resourceType] via getSummary() / navigateToPath(). A plain JSON Schema like the one accepted in the new test will register successfully here, then return fields: [] / no sections instead of failing loudly or being normalized, which can mislead agents using harness_schema to build payloads.

? { ...SCHEMAS, ...Object.fromEntries(Object.entries(additionalSchemas).map(([k, v]) => [k, v.schema])) }
: { ...SCHEMAS };
const availableSchemas = Object.keys(allSchemas);

server.registerTool(
"harness_schema",
Expand Down Expand Up @@ -213,7 +229,7 @@ export function registerSchemaTool(server: McpServer): void {
return errorResult("resource_type is required for schema lookups. Use example_search to search examples without specifying a resource type.");
}

const schema = SCHEMAS[args.resource_type as keyof typeof SCHEMAS] as Record<string, unknown>;
const schema = allSchemas[args.resource_type] as Record<string, unknown>;

// No path → return summary with available examples
if (!args.path) {
Expand Down
5 changes: 3 additions & 2 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ import { registerSearchTool } from "./harness-search.js";
import { registerDescribeTool } from "./harness-describe.js";
import { registerStatusTool } from "./harness-status.js";
import { registerSchemaTool } from "./harness-schema.js";
import type { SchemaEntry } from "../data/schemas/index.js";
import "../data/examples/load-all.js";


export function registerAllTools(server: McpServer, registry: Registry, client: HarnessClient, config: Config): void {
export function registerAllTools(server: McpServer, registry: Registry, client: HarnessClient, config: Config, additionalSchemas?: Record<string, SchemaEntry>): void {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still doesn't make the new schema path reachable from the shipped server. src/index.ts is unchanged and still calls registerAllTools(server, registry, client, config) / registerAllResources(...) with no fifth argument, and this branch also never adds the advertised registerSchema() export in src/data/schemas/index.ts. So the only way to hit additionalSchemas is from internal helper callers that manually keep both tool and resource registration in sync, which means the production binary/package still cannot register DashboardContract-style schemas at startup.

Copy link
Copy Markdown
Collaborator Author

@sunilgattupalle sunilgattupalle May 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is expected for now.

registerListTool(server, registry, client);
registerGetTool(server, registry, client);
registerCreateTool(server, registry, client);
Expand All @@ -28,5 +29,5 @@ export function registerAllTools(server: McpServer, registry: Registry, client:
registerSearchTool(server, registry, client);
registerDescribeTool(server, registry);
registerStatusTool(server, registry, client, config);
registerSchemaTool(server);
registerSchemaTool(server, additionalSchemas);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

additionalSchemas gets threaded through here, but the actual server constructor still calls registerAllTools(server, registry, client, config) / registerAllResources(...) without a fifth argument in src/index.ts. With no registerSchema() or other startup hook left on the branch, this new path is still test-only: extension schemas cannot actually reach the shipped MCP server.

}
31 changes: 29 additions & 2 deletions tests/resources/harness-schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
import { describe, it, expect } from "vitest";
import { isValidSchemaName } from "../../src/resources/harness-schema.js";
import { describe, it, expect, vi } from "vitest";
import { isValidSchemaName, registerHarnessSchemaResource } from "../../src/resources/harness-schema.js";
import type { SchemaEntry } from "../../src/data/schemas/index.js";

describe("registerHarnessSchemaResource collision guard", () => {
it("throws when additionalSchemas key collides with a built-in schema name", () => {
const server = { registerResource: vi.fn() } as any;
const e: SchemaEntry = { schema: { type: "object" }, description: "test", group: "test" };
expect(() =>
registerHarnessSchemaResource(server, { pipeline: e }),
).toThrow("conflicts with a built-in schema name");
});
});

describe("isValidSchemaName with extension schemas", () => {
it("returns false for an extension schema name when no extensions passed", () => {
expect(isValidSchemaName("DashboardContract")).toBe(false);
});

it("returns true for an extension schema name when passed in merged set", () => {
const mergedNames = ["pipeline", "DashboardContract"];
expect(isValidSchemaName("DashboardContract", mergedNames)).toBe(true);
});

it("returns false for unknown name even with merged set", () => {
const mergedNames = ["pipeline", "DashboardContract"];
expect(isValidSchemaName("unknown", mergedNames)).toBe(false);
});
});

describe("harness-schema resource", () => {
describe("isValidSchemaName", () => {
Expand Down
104 changes: 104 additions & 0 deletions tests/tools/harness-schema-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, it, expect, vi } from "vitest";
import type { ToolResult } from "../../src/utils/response-formatter.js";
import type { SchemaEntry } from "../../src/data/schemas/index.js";
import { registerSchemaTool } from "../../src/tools/harness-schema.js";

function entry(schema: Record<string, any>): SchemaEntry {
return { schema, description: "test", group: "test" };
}

function makeMcpServer() {
const tools = new Map<string, { handler: (...args: unknown[]) => Promise<ToolResult> }>();
return {
registerTool: vi.fn((name: string, _schema: unknown, handler: (...args: unknown[]) => Promise<ToolResult>) => {
tools.set(name, { handler });
}),
async call(name: string, args: Record<string, unknown>): Promise<ToolResult> {
const tool = tools.get(name);
if (!tool) throw new Error(`Tool "${name}" not registered`);
const extra = { signal: new AbortController().signal, sendNotification: vi.fn(), _meta: {} };
return tool.handler(args, extra) as Promise<ToolResult>;
},
} as any;
}

function parseResult(result: ToolResult): unknown {
return JSON.parse(result.content[0]!.text);
}

describe("registerSchemaTool additionalSchemas", () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: these new cases only prove that the internal helper works when called directly. They do not exercise createHarnessServer() or any packaged stdio/HTTP startup path, so they miss the broken wiring above.

Please add at least one test or smoke check that goes through the real bootstrap and verifies the extra schema is visible from the actual exported server surface, not just from registerSchemaTool() / registerHarnessSchemaResource() in isolation.

it("accepts additionalSchemas without throwing", () => {
const server = makeMcpServer();
expect(() =>
registerSchemaTool(server, { DashboardContract: entry({ type: "object", properties: { id: { type: "string" } } }) })
).not.toThrow();
});

it("registers without additionalSchemas (backwards compat)", () => {
const server = makeMcpServer();
expect(() => registerSchemaTool(server)).not.toThrow();
});

it("throws when additionalSchemas key collides with a built-in schema name", () => {
const server = makeMcpServer();
expect(() =>
registerSchemaTool(server, { pipeline: entry({ type: "object" }) }),
).toThrow("conflicts with a built-in schema name");
});

it("handler returns fields from a Harness-layout extension schema (definitions[type][type])", async () => {
const server = makeMcpServer();
const dashboardSchema = entry({
definitions: {
DashboardContract: {
DashboardContract: {
type: "object",
properties: { title: { type: "string" }, widgets: { type: "array" } },
required: ["title"],
},
},
},
});
registerSchemaTool(server, { DashboardContract: dashboardSchema });

const result = await server.call("harness_schema", { resource_type: "DashboardContract" });
const parsed = parseResult(result) as Record<string, unknown>;

expect(parsed.resource_type).toBe("DashboardContract");
expect(parsed.fields).toEqual([
{ name: "title", type: "string", required: true },
{ name: "widgets", type: "array", required: false },
]);
});

it("handler returns fields from a plain JSON Schema extension schema (root-level properties)", async () => {
const server = makeMcpServer();
registerSchemaTool(server, {
MyExtension: entry({
type: "object",
properties: { name: { type: "string" }, count: { type: "number" } },
required: ["name"],
}),
});

const result = await server.call("harness_schema", { resource_type: "MyExtension" });
const parsed = parseResult(result) as Record<string, unknown>;

expect(parsed.resource_type).toBe("MyExtension");
expect(parsed.fields).toEqual([
{ name: "name", type: "string", required: true },
{ name: "count", type: "number", required: false },
]);
});

it("handler still returns built-in schema content when no additionalSchemas", async () => {
const server = makeMcpServer();
registerSchemaTool(server);

const result = await server.call("harness_schema", { resource_type: "pipeline" });
const parsed = parseResult(result) as Record<string, unknown>;

expect(parsed.resource_type).toBe("pipeline");
expect(Array.isArray(parsed.fields)).toBe(true);
});
});
Loading