Skip to content

Commit 1ce1da3

Browse files
feat: support additional schemas via additionalSchemas parameter (#166)
* feat: add registerSchema() for runtime schema registration Extension servers (e.g., mcpServerInternal) can now register additional schemas at startup without modifying the OSS package. Call registerSchema() before registerSchemaTool() so the new schemas appear in harness_schema. SCHEMAS and VALID_SCHEMAS are now mutable (string-keyed) to support this. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: revert schemas/index.ts to static typed form, remove registerSchema() * feat: add additionalSchemas param to registerSchemaTool() * feat: add additionalSchemas param to registerHarnessSchemaResource() * feat: thread additionalSchemas through registerAllTools and registerAllResources * fix: warn on name collision in additionalSchemas, add handler-level extension schema test * fix: throw on built-in schema name collision; getSummary falls back to root-level properties - registerSchemaTool/registerHarnessSchemaResource now throw instead of warn when additionalSchemas contains a key that matches a built-in schema name - getSummary falls back to root-level properties for plain JSON Schemas that don't use the Harness definitions[type][type] layout - Tests cover collision-throws, Harness-layout summary, and plain JSON Schema summary Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: introduce SchemaEntry type — additionalSchemas now requires description and group --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9547efa commit 1ce1da3

7 files changed

Lines changed: 196 additions & 27 deletions

File tree

src/data/schemas/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import agentPipeline from "./local/agent-pipeline.js";
1515
type V0SchemaKey = "pipeline" | "template" | "trigger";
1616
type V1SchemaKey = "pipeline_v1" | "template_v1" | "trigger_v1" | "inputSet_v1" | "overlayInputSet_v1" | "service_v1" | "infra_v1";
1717
type LocalSchemaKey = "agent-pipeline";
18-
type AllSchemaKeys = V0SchemaKey | V1SchemaKey | LocalSchemaKey;
18+
export type AllSchemaKeys = V0SchemaKey | V1SchemaKey | LocalSchemaKey;
1919

2020
export const SCHEMAS: Record<AllSchemaKeys, Record<string, any>> = {
2121
"pipeline": pipeline,
@@ -31,8 +31,15 @@ export const SCHEMAS: Record<AllSchemaKeys, Record<string, any>> = {
3131
"agent-pipeline": agentPipeline,
3232
};
3333

34-
export const VALID_SCHEMAS = Object.keys(SCHEMAS) as AllSchemaKeys[];
34+
export const VALID_SCHEMAS: AllSchemaKeys[] = Object.keys(SCHEMAS) as AllSchemaKeys[];
3535
export type SchemaName = AllSchemaKeys;
3636

37+
/** Metadata-wrapped schema entry. All registered schemas must provide description and group. */
38+
export type SchemaEntry = {
39+
schema: Record<string, any>;
40+
description: string;
41+
group: string;
42+
};
43+
3744
export const V0_SCHEMA_KEYS: V0SchemaKey[] = ["pipeline", "template", "trigger"];
3845
export const V1_SCHEMA_KEYS: V1SchemaKey[] = ["pipeline_v1", "template_v1", "trigger_v1", "inputSet_v1", "overlayInputSet_v1", "service_v1", "infra_v1"];

src/resources/harness-schema.ts

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,40 @@
11
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
33
import { createLogger } from "../utils/logger.js";
4-
import { SCHEMAS, VALID_SCHEMAS, type SchemaName } from "../data/schemas/index.js";
4+
import { SCHEMAS, VALID_SCHEMAS, type SchemaName, type SchemaEntry } from "../data/schemas/index.js";
55

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

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

12-
export function registerHarnessSchemaResource(server: McpServer): void {
12+
export function registerHarnessSchemaResource(
13+
server: McpServer,
14+
additionalSchemas?: Record<string, SchemaEntry>,
15+
): void {
16+
if (additionalSchemas) {
17+
for (const key of Object.keys(additionalSchemas)) {
18+
if (key in SCHEMAS) {
19+
throw new Error(`additionalSchemas key '${key}' conflicts with a built-in schema name`);
20+
}
21+
}
22+
}
23+
const allSchemas: Record<string, Record<string, any>> = additionalSchemas
24+
? { ...SCHEMAS, ...Object.fromEntries(Object.entries(additionalSchemas).map(([k, v]) => [k, v.schema])) }
25+
: { ...SCHEMAS };
26+
const allSchemaNames = Object.keys(allSchemas);
27+
1328
const template = new ResourceTemplate("schema:///{schemaName}", {
1429
list: async () => ({
15-
resources: VALID_SCHEMAS.map((name) => ({
30+
resources: allSchemaNames.map((name) => ({
1631
uri: `schema:///${name}`,
1732
name: `${name} schema`,
1833
})),
1934
}),
2035
complete: {
2136
schemaName: (value) =>
22-
VALID_SCHEMAS.filter((s) => s.startsWith(value)),
37+
allSchemaNames.filter((s) => s.startsWith(value)),
2338
},
2439
});
2540

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

37-
if (!isValidSchemaName(schemaName)) {
52+
if (!isValidSchemaName(schemaName, allSchemaNames)) {
3853
throw new Error(
39-
`Unknown schema '${schemaName}'. Valid schemas: ${VALID_SCHEMAS.join(", ")}`,
54+
`Unknown schema '${schemaName}'. Valid schemas: ${allSchemaNames.join(", ")}`,
4055
);
4156
}
4257

43-
const schema = SCHEMAS[schemaName];
58+
const schema = allSchemas[schemaName];
4459

4560
return {
4661
contents: [
@@ -55,5 +70,3 @@ export function registerHarnessSchemaResource(server: McpServer): void {
5570
);
5671
}
5772

58-
// Exported for testing
59-
export { isValidSchemaName };

src/resources/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import type { Config } from "../config.js";
66
import { registerPipelineYamlResource } from "./pipeline-yaml.js";
77
import { registerExecutionSummaryResource } from "./execution-summary.js";
88
import { registerHarnessSchemaResource } from "./harness-schema.js";
9+
import type { SchemaEntry } from "../data/schemas/index.js";
910

10-
export function registerAllResources(server: McpServer, registry: Registry, client: HarnessClient, config: Config): void {
11+
export function registerAllResources(server: McpServer, registry: Registry, client: HarnessClient, config: Config, additionalSchemas?: Record<string, SchemaEntry>): void {
1112
registerPipelineYamlResource(server, registry, client, config);
1213
registerExecutionSummaryResource(server, registry, client, config);
13-
registerHarnessSchemaResource(server);
14+
registerHarnessSchemaResource(server, additionalSchemas);
1415
}

src/tools/harness-schema.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as z from "zod/v4";
22
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
33
import { jsonResult, errorResult } from "../utils/response-formatter.js";
44
import { createLogger } from "../utils/logger.js";
5-
import { SCHEMAS, VALID_SCHEMAS } from "../data/schemas/index.js";
5+
import { SCHEMAS, VALID_SCHEMAS, type SchemaEntry } from "../data/schemas/index.js";
66
import { getExample, searchExamples, getExamplesForResource } from "../data/examples/index.js";
77

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

101-
// Get the root resource definition
102-
const rootDef = definitions?.[resourceType]?.[resourceType] as Record<string, unknown> | undefined;
100+
// Harness-generated schemas nest the root definition under definitions[type][type].
101+
// Plain JSON Schemas (extension schemas) place properties at the root level.
102+
const harnessRootDef = definitions?.[resourceType]?.[resourceType] as Record<string, unknown> | undefined;
103+
const rootDef = harnessRootDef ?? (schema.properties ? schema : undefined) as Record<string, unknown> | undefined;
104+
105+
const sections = definitions ? Object.keys(definitions[resourceType] ?? {}) : [];
103106
const properties = rootDef?.properties as Record<string, unknown> | undefined;
104107
const required = rootDef?.required as string[] | undefined;
105108

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

127-
export function registerSchemaTool(server: McpServer): void {
128-
const availableSchemas = VALID_SCHEMAS;
130+
export function registerSchemaTool(
131+
server: McpServer,
132+
additionalSchemas?: Record<string, SchemaEntry>,
133+
): void {
134+
if (additionalSchemas) {
135+
for (const key of Object.keys(additionalSchemas)) {
136+
if (key in SCHEMAS) {
137+
throw new Error(`additionalSchemas key '${key}' conflicts with a built-in schema name`);
138+
}
139+
}
140+
}
141+
const allSchemas: Record<string, Record<string, any>> = additionalSchemas
142+
? { ...SCHEMAS, ...Object.fromEntries(Object.entries(additionalSchemas).map(([k, v]) => [k, v.schema])) }
143+
: { ...SCHEMAS };
144+
const availableSchemas = Object.keys(allSchemas);
129145

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

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

218234
// No path → return summary with available examples
219235
if (!args.path) {

src/tools/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ import { registerSearchTool } from "./harness-search.js";
1414
import { registerDescribeTool } from "./harness-describe.js";
1515
import { registerStatusTool } from "./harness-status.js";
1616
import { registerSchemaTool } from "./harness-schema.js";
17+
import type { SchemaEntry } from "../data/schemas/index.js";
1718
import "../data/examples/load-all.js";
1819

1920

20-
export function registerAllTools(server: McpServer, registry: Registry, client: HarnessClient, config: Config): void {
21+
export function registerAllTools(server: McpServer, registry: Registry, client: HarnessClient, config: Config, additionalSchemas?: Record<string, SchemaEntry>): void {
2122
registerListTool(server, registry, client);
2223
registerGetTool(server, registry, client);
2324
registerCreateTool(server, registry, client);
@@ -28,5 +29,5 @@ export function registerAllTools(server: McpServer, registry: Registry, client:
2829
registerSearchTool(server, registry, client);
2930
registerDescribeTool(server, registry);
3031
registerStatusTool(server, registry, client, config);
31-
registerSchemaTool(server);
32+
registerSchemaTool(server, additionalSchemas);
3233
}

tests/resources/harness-schema.test.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
1-
import { describe, it, expect } from "vitest";
2-
import { isValidSchemaName } from "../../src/resources/harness-schema.js";
1+
import { describe, it, expect, vi } from "vitest";
2+
import { isValidSchemaName, registerHarnessSchemaResource } from "../../src/resources/harness-schema.js";
3+
import type { SchemaEntry } from "../../src/data/schemas/index.js";
4+
5+
describe("registerHarnessSchemaResource collision guard", () => {
6+
it("throws when additionalSchemas key collides with a built-in schema name", () => {
7+
const server = { registerResource: vi.fn() } as any;
8+
const e: SchemaEntry = { schema: { type: "object" }, description: "test", group: "test" };
9+
expect(() =>
10+
registerHarnessSchemaResource(server, { pipeline: e }),
11+
).toThrow("conflicts with a built-in schema name");
12+
});
13+
});
14+
15+
describe("isValidSchemaName with extension schemas", () => {
16+
it("returns false for an extension schema name when no extensions passed", () => {
17+
expect(isValidSchemaName("DashboardContract")).toBe(false);
18+
});
19+
20+
it("returns true for an extension schema name when passed in merged set", () => {
21+
const mergedNames = ["pipeline", "DashboardContract"];
22+
expect(isValidSchemaName("DashboardContract", mergedNames)).toBe(true);
23+
});
24+
25+
it("returns false for unknown name even with merged set", () => {
26+
const mergedNames = ["pipeline", "DashboardContract"];
27+
expect(isValidSchemaName("unknown", mergedNames)).toBe(false);
28+
});
29+
});
330

431
describe("harness-schema resource", () => {
532
describe("isValidSchemaName", () => {
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import type { ToolResult } from "../../src/utils/response-formatter.js";
3+
import type { SchemaEntry } from "../../src/data/schemas/index.js";
4+
import { registerSchemaTool } from "../../src/tools/harness-schema.js";
5+
6+
function entry(schema: Record<string, any>): SchemaEntry {
7+
return { schema, description: "test", group: "test" };
8+
}
9+
10+
function makeMcpServer() {
11+
const tools = new Map<string, { handler: (...args: unknown[]) => Promise<ToolResult> }>();
12+
return {
13+
registerTool: vi.fn((name: string, _schema: unknown, handler: (...args: unknown[]) => Promise<ToolResult>) => {
14+
tools.set(name, { handler });
15+
}),
16+
async call(name: string, args: Record<string, unknown>): Promise<ToolResult> {
17+
const tool = tools.get(name);
18+
if (!tool) throw new Error(`Tool "${name}" not registered`);
19+
const extra = { signal: new AbortController().signal, sendNotification: vi.fn(), _meta: {} };
20+
return tool.handler(args, extra) as Promise<ToolResult>;
21+
},
22+
} as any;
23+
}
24+
25+
function parseResult(result: ToolResult): unknown {
26+
return JSON.parse(result.content[0]!.text);
27+
}
28+
29+
describe("registerSchemaTool additionalSchemas", () => {
30+
it("accepts additionalSchemas without throwing", () => {
31+
const server = makeMcpServer();
32+
expect(() =>
33+
registerSchemaTool(server, { DashboardContract: entry({ type: "object", properties: { id: { type: "string" } } }) })
34+
).not.toThrow();
35+
});
36+
37+
it("registers without additionalSchemas (backwards compat)", () => {
38+
const server = makeMcpServer();
39+
expect(() => registerSchemaTool(server)).not.toThrow();
40+
});
41+
42+
it("throws when additionalSchemas key collides with a built-in schema name", () => {
43+
const server = makeMcpServer();
44+
expect(() =>
45+
registerSchemaTool(server, { pipeline: entry({ type: "object" }) }),
46+
).toThrow("conflicts with a built-in schema name");
47+
});
48+
49+
it("handler returns fields from a Harness-layout extension schema (definitions[type][type])", async () => {
50+
const server = makeMcpServer();
51+
const dashboardSchema = entry({
52+
definitions: {
53+
DashboardContract: {
54+
DashboardContract: {
55+
type: "object",
56+
properties: { title: { type: "string" }, widgets: { type: "array" } },
57+
required: ["title"],
58+
},
59+
},
60+
},
61+
});
62+
registerSchemaTool(server, { DashboardContract: dashboardSchema });
63+
64+
const result = await server.call("harness_schema", { resource_type: "DashboardContract" });
65+
const parsed = parseResult(result) as Record<string, unknown>;
66+
67+
expect(parsed.resource_type).toBe("DashboardContract");
68+
expect(parsed.fields).toEqual([
69+
{ name: "title", type: "string", required: true },
70+
{ name: "widgets", type: "array", required: false },
71+
]);
72+
});
73+
74+
it("handler returns fields from a plain JSON Schema extension schema (root-level properties)", async () => {
75+
const server = makeMcpServer();
76+
registerSchemaTool(server, {
77+
MyExtension: entry({
78+
type: "object",
79+
properties: { name: { type: "string" }, count: { type: "number" } },
80+
required: ["name"],
81+
}),
82+
});
83+
84+
const result = await server.call("harness_schema", { resource_type: "MyExtension" });
85+
const parsed = parseResult(result) as Record<string, unknown>;
86+
87+
expect(parsed.resource_type).toBe("MyExtension");
88+
expect(parsed.fields).toEqual([
89+
{ name: "name", type: "string", required: true },
90+
{ name: "count", type: "number", required: false },
91+
]);
92+
});
93+
94+
it("handler still returns built-in schema content when no additionalSchemas", async () => {
95+
const server = makeMcpServer();
96+
registerSchemaTool(server);
97+
98+
const result = await server.call("harness_schema", { resource_type: "pipeline" });
99+
const parsed = parseResult(result) as Record<string, unknown>;
100+
101+
expect(parsed.resource_type).toBe("pipeline");
102+
expect(Array.isArray(parsed.fields)).toBe(true);
103+
});
104+
});

0 commit comments

Comments
 (0)