Skip to content
Draft
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
20 changes: 17 additions & 3 deletions scripts/sync-schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,26 @@ type V1SchemaKey = ${v1Keys.join(" | ")};
type LocalSchemaKey = ${localKeys.join(" | ")};
type AllSchemaKeys = V0SchemaKey | V1SchemaKey | LocalSchemaKey;

export const SCHEMAS: Record<AllSchemaKeys, Record<string, any>> = {
export const SCHEMAS: Record<string, Record<string, unknown>> = {
${props}
};

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

/**
* Register an additional schema at runtime.
* Internal/extension servers should call this during startup, before
* registering schema tools/resources, so MCP clients see the new schema name.
*/
export function registerSchema(name: string, schema: Record<string, unknown>): void {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

registerSchema() currently trusts any object shape. I was able to register {} successfully, which adds the name to VALID_SCHEMAS, and then harness_schema(resource_type=...) returns an empty summary instead of a clear error. Since this repo prefers fail-loudly contracts, please validate the expected definitions[name][name] structure before mutating the registry, and add a negative test alongside the new happy-path cases.

if (SCHEMAS[name]) {
throw new Error(\`Schema '\${name}' already registered.\`);
}

SCHEMAS[name] = schema;
VALID_SCHEMAS.push(name);
}

export const V0_SCHEMA_KEYS: V0SchemaKey[] = [${v0Keys.join(", ")}];
export const V1_SCHEMA_KEYS: V1SchemaKey[] = [${v1Keys.join(", ")}];
Expand Down
20 changes: 17 additions & 3 deletions src/data/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type V1SchemaKey = "pipeline_v1" | "template_v1" | "trigger_v1" | "inputSet_v1"
type LocalSchemaKey = "agent-pipeline";
type AllSchemaKeys = V0SchemaKey | V1SchemaKey | LocalSchemaKey;

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

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

/**
* Register an additional schema at runtime.
* Internal/extension servers should call this during startup, before
* registering schema tools/resources, so MCP clients see the new schema name.
*/
export function registerSchema(name: string, schema: Record<string, unknown>): void {
if (SCHEMAS[name]) {
throw new Error(`Schema '${name}' already registered.`);
}

SCHEMAS[name] = schema;
VALID_SCHEMAS.push(name);
}

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"];
19 changes: 19 additions & 0 deletions tasks/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,22 @@
- Clarified in README that hosted `https://mcp.harness.io/mcp` is managed and cannot be pointed at Harness0 from Claude/Cursor/Cowork client config; Support must configure hosted MCP for that target environment.
- Updated MCPB manifest descriptions so `HARNESS_BASE_URL` covers private SaaS hosts such as `https://harness0.harness.io`, not just self-managed installs.
- Verified with `pnpm build` and `pnpm docs:check`.

## PR #166 Schema Registration Follow-up (2026-05-10)
- [x] Read the Slack request thread and inspect PR #166 metadata/diff
- [x] Trace `harness_schema` registration through schema data, tool, and resource modules
- [x] Add focused regression coverage for runtime schema registration
- [x] Generate the runtime registration hook from `scripts/sync-schemas.js`
- [x] Verify focused tests, typecheck, build, and full test suite
- [x] Commit, push, open PR, and reply in the Slack thread

### Plan
- Keep `registerSchema(name, schema)` in `src/data/schemas/index.ts`, but make `scripts/sync-schemas.js` emit it so schema syncs do not remove the hook.
- Preserve startup ordering semantics: internal servers must register schemas before `registerSchemaTool()` / `registerHarnessSchemaResource()` so tool enum metadata and resource lists include the new names.
- Add tests against the exported schema registry and resource validation helper to prove new schema names are accepted and duplicate names fail loudly.

### Review
- Found PR #166's hook was added directly to the generated schema index, so `pnpm sync-schemas` would remove it.
- Added the same `registerSchema()` output to `scripts/sync-schemas.js` and the current generated `src/data/schemas/index.ts`.
- Added tests proving runtime-registered schemas update shared validation state and appear in the `harness_schema` tool input schema when registered before tool setup.
- Verified with `pnpm test tests/resources/harness-schema.test.ts`, `pnpm typecheck`, `pnpm build`, and full `pnpm test`.
95 changes: 94 additions & 1 deletion tests/resources/harness-schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { registerSchema, SCHEMAS, VALID_SCHEMAS } from "../../src/data/schemas/index.js";
import { isValidSchemaName } from "../../src/resources/harness-schema.js";
import { registerSchemaTool } from "../../src/tools/harness-schema.js";
import type { ToolResult } from "../../src/utils/response-formatter.js";

function parseToolResult(result: ToolResult): unknown {
const content = result.content[0];
if (content?.type !== "text") {
throw new Error("Expected text tool result");
}

return JSON.parse(content.text);
}

describe("harness-schema resource", () => {
describe("isValidSchemaName", () => {
Expand Down Expand Up @@ -29,5 +41,86 @@ describe("harness-schema resource", () => {
expect(isValidSchemaName("Pipeline")).toBe(false);
expect(isValidSchemaName("connector")).toBe(false);
});

it("returns true for schemas registered at runtime", () => {
registerSchema("dashboard-contract-test", {
definitions: {
"dashboard-contract-test": {
"dashboard-contract-test": {
type: "object",
properties: {
title: { type: "string" },
},
},
},
},
});

expect(isValidSchemaName("dashboard-contract-test")).toBe(true);
expect(VALID_SCHEMAS).toContain("dashboard-contract-test");
expect(SCHEMAS["dashboard-contract-test"]).toMatchObject({
definitions: {
"dashboard-contract-test": expect.any(Object),
},
});
});

it("throws when registering a duplicate schema name", () => {
registerSchema("duplicate-dashboard-contract-test", {
definitions: {
"duplicate-dashboard-contract-test": {},
},
});

expect(() =>
registerSchema("duplicate-dashboard-contract-test", {
definitions: {
"duplicate-dashboard-contract-test": {},
},
}),
).toThrow("Schema 'duplicate-dashboard-contract-test' already registered.");
});

it("advertises runtime schemas when the schema tool is registered after them", async () => {
registerSchema("tool-dashboard-contract-test", {
definitions: {
"tool-dashboard-contract-test": {
"tool-dashboard-contract-test": {
type: "object",
properties: {
name: { type: "string" },
},
},
},
},
});

const registerTool = vi.fn();
registerSchemaTool({ registerTool } as unknown as Parameters<typeof registerSchemaTool>[0]);

const schemaConfig = registerTool.mock.calls[0]?.[1] as {
inputSchema: {
resource_type: {
parse(value: string): string;
};
};
};
expect(schemaConfig.inputSchema.resource_type.parse("tool-dashboard-contract-test")).toBe(
"tool-dashboard-contract-test",
);

const handler = registerTool.mock.calls[0]?.[2] as (args: Record<string, unknown>) => Promise<ToolResult>;
const result = await handler({ resource_type: "tool-dashboard-contract-test" });
expect(parseToolResult(result)).toMatchObject({
resource_type: "tool-dashboard-contract-test",
fields: [
{
name: "name",
type: "string",
required: false,
},
],
});
});
});
});
Loading