diff --git a/src/data/schemas/index.ts b/src/data/schemas/index.ts index 341e7dc7..53599c58 100644 --- a/src/data/schemas/index.ts +++ b/src/data/schemas/index.ts @@ -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> = { "pipeline": pipeline, @@ -31,8 +31,15 @@ export const SCHEMAS: Record> = { "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; + 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"]; diff --git a/src/resources/harness-schema.ts b/src/resources/harness-schema.ts index c34012b9..fcdeba3e 100644 --- a/src/resources/harness-schema.ts +++ b/src/resources/harness-schema.ts @@ -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, +): 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> = additionalSchemas + ? { ...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)), }, }); @@ -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: [ @@ -55,5 +70,3 @@ export function registerHarnessSchemaResource(server: McpServer): void { ); } -// Exported for testing -export { isValidSchemaName }; diff --git a/src/resources/index.ts b/src/resources/index.ts index b7e49e16..6f0dad66 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -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): void { registerPipelineYamlResource(server, registry, client, config); registerExecutionSummaryResource(server, registry, client, config); - registerHarnessSchemaResource(server); + registerHarnessSchemaResource(server, additionalSchemas); } diff --git a/src/tools/harness-schema.ts b/src/tools/harness-schema.ts index 31ebde04..84ac1d3f 100644 --- a/src/tools/harness-schema.ts +++ b/src/tools/harness-schema.ts @@ -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"); @@ -96,10 +96,13 @@ function navigateToPath( */ function getSummary(schema: Record, resourceType: string): Record { const definitions = schema.definitions as Record> | undefined; - const sections = definitions ? Object.keys(definitions[resourceType] ?? {}) : []; - // Get the root resource definition - const rootDef = definitions?.[resourceType]?.[resourceType] as Record | 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 | undefined; + const rootDef = harnessRootDef ?? (schema.properties ? schema : undefined) as Record | undefined; + + const sections = definitions ? Object.keys(definitions[resourceType] ?? {}) : []; const properties = rootDef?.properties as Record | undefined; const required = rootDef?.required as string[] | undefined; @@ -124,8 +127,21 @@ function getSummary(schema: Record, resourceType: string): Reco }; } -export function registerSchemaTool(server: McpServer): void { - const availableSchemas = VALID_SCHEMAS; +export function registerSchemaTool( + server: McpServer, + additionalSchemas?: Record, +): 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> = additionalSchemas + ? { ...SCHEMAS, ...Object.fromEntries(Object.entries(additionalSchemas).map(([k, v]) => [k, v.schema])) } + : { ...SCHEMAS }; + const availableSchemas = Object.keys(allSchemas); server.registerTool( "harness_schema", @@ -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; + const schema = allSchemas[args.resource_type] as Record; // No path → return summary with available examples if (!args.path) { diff --git a/src/tools/index.ts b/src/tools/index.ts index 0454e6df..69d8a922 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -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): void { registerListTool(server, registry, client); registerGetTool(server, registry, client); registerCreateTool(server, registry, client); @@ -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); } diff --git a/tests/resources/harness-schema.test.ts b/tests/resources/harness-schema.test.ts index ec50d93a..e81b1146 100644 --- a/tests/resources/harness-schema.test.ts +++ b/tests/resources/harness-schema.test.ts @@ -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", () => { diff --git a/tests/tools/harness-schema-tool.test.ts b/tests/tools/harness-schema-tool.test.ts new file mode 100644 index 00000000..937cbb72 --- /dev/null +++ b/tests/tools/harness-schema-tool.test.ts @@ -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): SchemaEntry { + return { schema, description: "test", group: "test" }; +} + +function makeMcpServer() { + const tools = new Map Promise }>(); + return { + registerTool: vi.fn((name: string, _schema: unknown, handler: (...args: unknown[]) => Promise) => { + tools.set(name, { handler }); + }), + async call(name: string, args: Record): Promise { + 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; + }, + } as any; +} + +function parseResult(result: ToolResult): unknown { + return JSON.parse(result.content[0]!.text); +} + +describe("registerSchemaTool additionalSchemas", () => { + 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; + + 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; + + 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; + + expect(parsed.resource_type).toBe("pipeline"); + expect(Array.isArray(parsed.fields)).toBe(true); + }); +});