11import { 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 */
87136function 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) {
242287export {
243288 buildZodSchema ,
244289 buildOutputZodSchema ,
290+ toOpenApiJsonSchema ,
245291 resolveRef
246292} ;
0 commit comments