fix(mcp): z.string().nullable().optional() in update_workspace parent_id schema (KI-006) #10
+13
-11
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user