Skip to content

Commit 4ef021a

Browse files
authored
fix(ai): restore Zod v4 MCP schema emission (#14385) (#14386)
* fix(ai): restore Zod v4 MCP schema emission (#14385) * fix(build): support Commander 15 imports (#14387) * test(ai): accept Zod v4 nullable schema shape (#14385)
1 parent 096181c commit 4ef021a

37 files changed

Lines changed: 130 additions & 83 deletions

ai/daemons/orchestrator/services/DreamService.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fs from 'fs';
22
import path from 'path';
3-
import yaml from 'js-yaml';
3+
import * as yaml from 'js-yaml';
44
import { fileURLToPath } from 'url';
55
import crypto from 'crypto';
66
import { Memory_Config as aiConfig } from '../../../services.mjs';

ai/demo-agents/dev.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {Command} from 'commander';
1515
import fs from 'fs-extra';
1616
import path from 'path';
1717
import {fileURLToPath} from 'url';
18-
import yaml from 'js-yaml';
18+
import * as yaml from 'js-yaml';
1919
import dotenv from 'dotenv';
2020
import {exec} from 'child_process';
2121
import {promisify} from 'util';

ai/demo-agents/pm.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
*/
1313

1414
import {Command} from 'commander';
15-
import yaml from 'js-yaml';
15+
import * as yaml from 'js-yaml';
1616
import dotenv from 'dotenv';
1717
import path from 'path';
1818
import {fileURLToPath} from 'url';

ai/mcp/ToolService.mjs

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import fs from 'fs';
2-
import yaml from 'js-yaml';
3-
import {zodToJsonSchema} from 'zod-to-json-schema';
4-
import {buildZodSchema, buildOutputZodSchema, resolveRef} from './validation/openApiValidator.mjs';
2+
import * as yaml from 'js-yaml';
3+
import {buildZodSchema,
4+
buildOutputZodSchema,
5+
resolveRef,
6+
toOpenApiJsonSchema} from './validation/openApiValidator.mjs';
57
import Base from '../../src/core/Base.mjs';
68

79
/**
@@ -135,15 +137,12 @@ class ToolService_tmp extends Base {
135137
const toolTier = operation['x-neo-tool-tier'] || null;
136138

137139
const inputZodSchema = buildZodSchema(openApiDocument, operation);
138-
const inputJsonSchema = zodToJsonSchema(inputZodSchema, {
139-
target : 'openApi3',
140-
$refStrategy: 'none'
141-
});
140+
const inputJsonSchema = toOpenApiJsonSchema(inputZodSchema);
142141

143142
const outputZodSchema = buildOutputZodSchema(openApiDocument, operation);
144143
let outputJsonSchema = null;
145144
if (outputZodSchema) {
146-
outputJsonSchema = zodToJsonSchema(outputZodSchema);
145+
outputJsonSchema = toOpenApiJsonSchema(outputZodSchema);
147146
}
148147

149148
const argNames = (operation.parameters || []).map(p => p.name);

ai/mcp/validation/openApiValidator.mjs

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,54 @@
11
import {z} from 'zod';
22

3+
/**
4+
* @summary Convert a Zod schema to the OpenAPI-compatible JSON Schema shape used by MCP tools.
5+
*
6+
* Zod v4 owns JSON Schema emission directly; using the former external bridge
7+
* against v4 schemas emits hollow `{}` payloads. Zod's passthrough objects
8+
* emit `additionalProperties: {}` which is JSON-Schema-equivalent to `true`; the
9+
* MCP contract historically exposes `true`, so normalize that property while
10+
* preserving empty schemas elsewhere (notably `items: {}` for unknown arrays).
11+
*
12+
* @param {z.ZodType} zodSchema Zod schema to expose through `tools/list`.
13+
* @returns {object} OpenAPI 3.0-compatible JSON Schema.
14+
*/
15+
function toOpenApiJsonSchema(zodSchema) {
16+
const jsonSchema = z.toJSONSchema(zodSchema, {target: 'openapi-3.0'});
17+
18+
return normalizeOpenApiJsonSchema(JSON.parse(JSON.stringify(jsonSchema)));
19+
}
20+
21+
/**
22+
* @summary Normalize Zod v4 JSON Schema output for Neo's MCP compatibility contract.
23+
* @param {any} node JSON Schema node.
24+
* @returns {any} The normalized node.
25+
*/
26+
function normalizeOpenApiJsonSchema(node) {
27+
if (!node || typeof node !== 'object') {
28+
return node;
29+
}
30+
31+
if (Array.isArray(node)) {
32+
node.forEach(normalizeOpenApiJsonSchema);
33+
return node;
34+
}
35+
36+
if (
37+
node.additionalProperties &&
38+
typeof node.additionalProperties === 'object' &&
39+
!Array.isArray(node.additionalProperties) &&
40+
Object.keys(node.additionalProperties).length === 0
41+
) {
42+
node.additionalProperties = true;
43+
}
44+
45+
for (const value of Object.values(node)) {
46+
normalizeOpenApiJsonSchema(value);
47+
}
48+
49+
return node;
50+
}
51+
352
/**
453
* Dynamically constructs a Zod schema for a tool's input arguments based on its
554
* OpenAPI operation definition. This schema is used for robust runtime validation
@@ -80,8 +129,8 @@ function resolveRef(doc, ref) {
80129
* `additionalProperties` are emitted via `.passthrough()`
81130
* so the resulting JSON Schema tolerates extra fields.
82131
* Used for *output* schemas where implementations may
83-
* return more fields than the OpenAPI contract declares
84-
* (see #9837). Input schemas stay strict.
132+
* return more fields than the OpenAPI contract declares.
133+
* Input schemas stay strict.
85134
* @returns {z.ZodType} A Zod schema representing the OpenAPI schema.
86135
*/
87136
function buildZodSchemaFromNode(doc, schema, opts = {}) {
@@ -128,7 +177,7 @@ function buildZodSchemaFromNode(doc, schema, opts = {}) {
128177
} else if (lenient) {
129178
// Output schemas tolerate server-side drift: emit `additionalProperties: true`
130179
// so strict MCP clients (e.g. GitHub Copilot) accept responses that carry
131-
// fields the OpenAPI contract forgot to declare. See #9837.
180+
// fields the OpenAPI contract forgot to declare.
132181
zodSchema = zodSchema.passthrough();
133182
} else if (!hasProperties && !hasAdditionalDef) {
134183
// Open-bag INPUT: OpenAPI declared `type: object` with no `properties` and no
@@ -137,17 +186,16 @@ function buildZodSchemaFromNode(doc, schema, opts = {}) {
137186
// `properties` field, `find_instances`'s `selector`, `modify_state_provider`'s
138187
// `data`). Without passthrough, Zod's strict parse silently strips the entire
139188
// payload to `{}`, producing hollow `{success: true}` responses while the
140-
// worker executes `instance.set({})` — a no-op. See #10070.
189+
// worker executes `instance.set({})` — a no-op.
141190
zodSchema = zodSchema.passthrough();
142191
}
143192
} else if (schema.type === 'array') {
144193
if (schema.items) {
145194
zodSchema = z.array(buildZodSchemaFromNode(doc, schema.items, opts));
146195
} else {
147-
// Fallback for OpenAPI arrays declared without `items`. We use z.unknown() rather than
148-
// z.any() because zod-to-json-schema with target:'openApi3' emits z.any() as the bare
149-
// {"type":"array"} (stripping `items` entirely), which strict validators like GitHub
150-
// Copilot reject. z.unknown() round-trips as {"type":"array","items":{}} — compliant.
196+
// Fallback for OpenAPI arrays declared without `items`. We use
197+
// z.unknown() because Zod v4's JSON Schema emitter preserves it as
198+
// {"type":"array","items":{}}, which strict MCP clients accept.
151199
zodSchema = z.array(z.unknown());
152200
}
153201
} else if (schema.type === 'string') {
@@ -173,11 +221,9 @@ function buildZodSchemaFromNode(doc, schema, opts = {}) {
173221
} else if (schema.type === 'boolean') {
174222
zodSchema = z.boolean();
175223
} else {
176-
// Catch-all for schemas without a declared type (e.g. `{}`, or an array `items: {}` sentinel).
177-
// We use z.unknown() rather than z.any() because zod-to-json-schema with target:'openApi3'
178-
// strips `items` from z.array(z.any()) but preserves it for z.array(z.unknown()). Runtime
179-
// validation is identical (both accept any value); only the emitted JSON Schema differs,
180-
// and only z.unknown() satisfies strict validators like GitHub Copilot's.
224+
// Catch-all for schemas without a declared type (e.g. `{}`, or an array
225+
// `items: {}` sentinel). Runtime validation accepts any value; z.unknown()
226+
// keeps the JSON Schema emission explicit enough for strict MCP clients.
181227
zodSchema = z.unknown();
182228
}
183229

@@ -211,7 +257,6 @@ function buildOutputZodSchema(doc, operation) {
211257
// JSON Schema sets `additionalProperties: true`. This is the output-side counterpart to
212258
// the input-side strictness: server implementations drift faster than OpenAPI contracts,
213259
// and strict MCP clients (GitHub Copilot) reject any response with undeclared fields.
214-
// See #9837.
215260
const outputOpts = {lenient: true};
216261

217262
if (schema) {
@@ -242,5 +287,6 @@ function buildOutputZodSchema(doc, operation) {
242287
export {
243288
buildZodSchema,
244289
buildOutputZodSchema,
290+
toOpenApiJsonSchema,
245291
resolveRef
246292
};

ai/services.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import 'dotenv/config';
22
import fs from 'fs';
33
import path from 'path';
44
import {fileURLToPath} from 'url';
5-
import yaml from 'js-yaml';
5+
import * as yaml from 'js-yaml';
66
import {buildZodSchema} from './mcp/validation/openApiValidator.mjs';
77

88
import Neo from '../src/Neo.mjs';

ai/services/ingestion/ConceptDiscoveryService.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fs from 'fs';
22
import path from 'path';
3-
import yaml from 'js-yaml';
3+
import * as yaml from 'js-yaml';
44
import {fileURLToPath} from 'url';
55
import Base from '../../../src/core/Base.mjs';
66
import ConceptService from '../../services/ConceptService.mjs';

ai/services/ingestion/IssueIngestor.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fs from 'fs';
22
import path from 'path';
3-
import yaml from 'js-yaml';
3+
import * as yaml from 'js-yaml';
44
import {fileURLToPath} from 'url';
55
import crypto from 'crypto';
66
import Base from '../../../src/core/Base.mjs';

ai/services/knowledge-base/IngestionService.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import logger from '../../mcp/server/knowledge-base/logger.mjs';
2020
import mcConfig from '../../mcp/server/memory-core/config.mjs';
2121
import os from 'os';
2222
import path from 'path';
23-
import yaml from 'js-yaml';
23+
import * as yaml from 'js-yaml';
2424
import {fileURLToPath} from 'url';
2525

2626
const __filename = fileURLToPath(import.meta.url);

buildScripts/build/all.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import chalk from 'chalk';
22
import {spawnSync} from 'child_process';
3-
import {Command} from 'commander/esm.mjs';
3+
import {Command} from 'commander';
44
import envinfo from 'envinfo';
55
import fs from 'fs-extra';
66
import inquirer from 'inquirer';

0 commit comments

Comments
 (0)