diff --git a/scripts/sync-schemas.js b/scripts/sync-schemas.js index 472cf6b2..7388b91a 100644 --- a/scripts/sync-schemas.js +++ b/scripts/sync-schemas.js @@ -118,12 +118,26 @@ type V1SchemaKey = ${v1Keys.join(" | ")}; type LocalSchemaKey = ${localKeys.join(" | ")}; type AllSchemaKeys = V0SchemaKey | V1SchemaKey | LocalSchemaKey; -export const SCHEMAS: Record> = { +export const SCHEMAS: Record> = { ${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): void { + 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(", ")}]; diff --git a/src/data/schemas/index.ts b/src/data/schemas/index.ts index 341e7dc7..82947a48 100644 --- a/src/data/schemas/index.ts +++ b/src/data/schemas/index.ts @@ -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> = { +export const SCHEMAS: Record> = { "pipeline": pipeline, "template": template, "trigger": trigger, @@ -31,8 +31,22 @@ export const SCHEMAS: Record> = { "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): 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"]; diff --git a/tasks/todo.md b/tasks/todo.md index 08008820..2bbdaadc 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -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`. diff --git a/tests/resources/harness-schema.test.ts b/tests/resources/harness-schema.test.ts index ec50d93a..d3ecbf02 100644 --- a/tests/resources/harness-schema.test.ts +++ b/tests/resources/harness-schema.test.ts @@ -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", () => { @@ -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[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) => Promise; + 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, + }, + ], + }); + }); }); });