diff --git a/known-issues.md b/known-issues.md index 5958fbe..e6c4a87 100644 --- a/known-issues.md +++ b/known-issues.md @@ -158,22 +158,24 @@ convention in CLAUDE.md. ## KI-006 — `anyOf` schemas cause `INVALID_ARGUMENTS` on valid inputs **File:** `src/tools/plugins.ts`, `src/tools/workspaces.ts` -**Status:** Resolved (PR: `fix/kind-ki006-anyof` #5) +**Status:** Resolved +**Resolved in:** PR `fix/kind-ki006-anyof` (#5, plugins.ts) and PR #10 (workspaces.ts) **Severity:** Medium ### Resolution -The root cause was `z.string().optional().nullable()` (zod chain order) in the -`update_workspace` tool's `parent_id` schema. `zod-to-json-schema` with -`strictUnions: true` produces `anyOf` for the `optional().nullable()` chain, but -`nullable().optional()` produces a clean `type: ["string","null"]` with no `anyOf`. +The root cause is `z.string().optional().nullable()` (zod chain order). +`zod-to-json-schema` with `strictUnions: true` produces `anyOf` for the +`optional().nullable()` chain, but `nullable().optional()` produces a clean +`type: ["string","null"]` with no `anyOf`. -Fix: changed `z.string().nullable().optional()` → `z.string().optional().nullable()` -in `src/tools/workspaces.ts:122`. Semantically equivalent (string | null | undefined), -no runtime behaviour change. +- **plugins.ts:** already used safe `nullable().optional()` order (PR #5). +- **workspaces.ts:** `parent_id: z.string().optional().nullable()` → `z.string().nullable().optional()`. + Semantically equivalent (string | null | undefined); no runtime behaviour change. -Regression guard added in `tests/__tests__/plugins-schema.test.ts`: mirrors all 6 -plugin tool schemas and asserts no `anyOf` in JSON Schema output. Includes a control -test documenting the known `optional().nullable()` zod-to-json-schema quirk. +Regression guard in `tests/__tests__/plugins-schema.test.ts`: mirrors all 6 +plugin tool schemas and the `update_workspace` workspace schema; asserts no +`anyOf` in JSON Schema output. Includes a control test documenting the +`optional().nullable()` → `anyOf` quirk. --- diff --git a/src/tools/workspaces.ts b/src/tools/workspaces.ts index ee65309..b57f7f6 100644 --- a/src/tools/workspaces.ts +++ b/src/tools/workspaces.ts @@ -119,7 +119,7 @@ export function registerWorkspaceTools(srv: McpServer) { name: z.string().optional(), role: z.string().optional(), tier: z.number().optional(), - parent_id: z.string().optional().nullable().describe("Set parent for nesting, null to un-nest"), + parent_id: z.string().nullable().optional().describe("Set parent for nesting, null to un-nest"), }, handleUpdateWorkspace ); diff --git a/tests/__tests__/plugins-schema.test.ts b/tests/__tests__/plugins-schema.test.ts index edc453d..50ffa47 100644 --- a/tests/__tests__/plugins-schema.test.ts +++ b/tests/__tests__/plugins-schema.test.ts @@ -54,6 +54,24 @@ describe("KI-006: plugin tool schemas are anyOf-free", () => { }), } as const; + // ------------------------------------------------------------------------- + // Schema fixtures — mirrors src/tools/workspaces.ts + // ------------------------------------------------------------------------- + + const workspaceSchemas = { + update_workspace: z.object({ + workspace_id: z.string(), + name: z.string().optional(), + role: z.string().optional(), + tier: z.number().optional(), + // NOTE: nullable must come BEFORE optional in the Zod chain. + // z.string().optional().nullable() → zod-to-json-schema produces `anyOf`, + // which causes INVALID_ARGUMENTS on valid null/undefined inputs in some + // MCP hosts. The safe order is z.string().nullable().optional(). + parent_id: z.string().nullable().optional().describe("Set parent for nesting, null to un-nest"), + }), + } as const; + // ------------------------------------------------------------------------- // Tests // ------------------------------------------------------------------------- @@ -67,6 +85,15 @@ describe("KI-006: plugin tool schemas are anyOf-free", () => { }); } + for (const [tool, schema] of Object.entries(workspaceSchemas)) { + describe(tool, () => { + const json = zodToJsonSchema(schema, { strictUnions: true }); + it("has no anyOf", () => { + expect(hasAnyOf(json)).toBe(false); + }); + }); + } + // ------------------------------------------------------------------------- // Control: document the optional().nullable() zod-to-json-schema quirk // -------------------------------------------------------------------------