Skip to content

Commit 15fded5

Browse files
authored
fix(core): Recursive schema parsing for array items with MCP transport (#286)
* fix(core): Recursive schema parsing for array items with MCP transport * remove unused var * Update test.protocol.ts * fix cases * fix tests * lint
1 parent 1cd049c commit 15fded5

File tree

4 files changed

+99
-16
lines changed

4 files changed

+99
-16
lines changed

packages/toolbox-core/src/toolbox_core/mcp/transportBase.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,16 +142,35 @@ export abstract class McpHttpTransportBase implements ITransport {
142142

143143
private _convertTypeSchema(schemaData: unknown): TypeSchema {
144144
const schema = schemaData as JsonSchema;
145-
if (schema.type === 'array') {
145+
const paramType = schema.type || 'string';
146+
147+
if (paramType === 'array') {
148+
let itemsSchema: TypeSchema | undefined;
149+
150+
// MCP strictly requires standard JSON Schema formatting:
151+
// https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool
152+
// This dictates using `items` for array types (https://json-schema.org/understanding-json-schema/reference/array#items)
153+
// and `additionalProperties` for maps (https://json-schema.org/understanding-json-schema/reference/object#additionalproperties).
154+
if (schema.items !== undefined && schema.items !== null) {
155+
// For third-party compatibility, skip strict typing if 'items' is a list (Draft 7 tuple validation).
156+
// Missing 'items' keys default natively to generic lists (list[Any]).
157+
if (typeof schema.items === 'object' && !Array.isArray(schema.items)) {
158+
itemsSchema = this._convertTypeSchema(schema.items);
159+
}
160+
}
161+
146162
return {
147163
type: 'array',
148-
items: this._convertTypeSchema(schema.items || {type: 'string'}),
164+
...(itemsSchema ? {items: itemsSchema} : {}),
149165
};
150-
} else if (schema.type === 'object') {
166+
} else if (paramType === 'object') {
151167
let additionalProperties: boolean | PrimitiveTypeSchema | undefined;
168+
152169
if (
153-
schema.additionalProperties &&
154-
typeof schema.additionalProperties === 'object'
170+
schema.additionalProperties !== undefined &&
171+
schema.additionalProperties !== null &&
172+
typeof schema.additionalProperties === 'object' &&
173+
!Array.isArray(schema.additionalProperties)
155174
) {
156175
additionalProperties = {
157176
type: schema.additionalProperties.type as
@@ -163,13 +182,14 @@ export abstract class McpHttpTransportBase implements ITransport {
163182
} else {
164183
additionalProperties = schema.additionalProperties !== false;
165184
}
185+
166186
return {
167187
type: 'object',
168188
additionalProperties,
169189
};
170190
} else {
171191
return {
172-
type: schema.type as
192+
type: paramType as
173193
| 'string'
174194
| 'integer'
175195
| 'float'

packages/toolbox-core/src/toolbox_core/protocol.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export type PrimitiveTypeSchema =
5555

5656
interface ArrayType {
5757
type: 'array';
58-
items: TypeSchema; // Recursive
58+
items?: TypeSchema; // Recursive
5959
}
6060
interface ObjectType {
6161
type: 'object';
@@ -91,7 +91,7 @@ const ZodTypeSchema: z.ZodType<TypeSchema> = z.lazy(() =>
9191
z.object({type: z.literal('integer')}),
9292
z.object({type: z.literal('float')}),
9393
z.object({type: z.literal('boolean')}),
94-
z.object({type: z.literal('array'), items: ZodTypeSchema}),
94+
z.object({type: z.literal('array'), items: ZodTypeSchema.optional()}),
9595
z.object({
9696
type: z.literal('object'),
9797
additionalProperties: z
@@ -140,6 +140,9 @@ function buildZodShapeFromTypeSchema(typeSchema: TypeSchema): ZodTypeAny {
140140
case 'boolean':
141141
return z.boolean();
142142
case 'array':
143+
if (!typeSchema.items) {
144+
return z.array(z.any());
145+
}
143146
return z.array(buildZodShapeFromTypeSchema(typeSchema.items));
144147
case 'object':
145148
if (typeof typeSchema.additionalProperties === 'object') {

packages/toolbox-core/test/mcp/test.base.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
/* eslint-disable @typescript-eslint/no-explicit-any */
1516
import {McpHttpTransportBase} from '../../src/toolbox_core/mcp/transportBase.js';
1617
import {Protocol, ZodManifest} from '../../src/toolbox_core/protocol.js';
1718
import axios, {AxiosInstance} from 'axios';
@@ -329,7 +330,7 @@ describe('McpHttpTransportBase', () => {
329330
expect(result.authRequired).toBeUndefined();
330331
});
331332

332-
it('should handle array without items (default to string)', () => {
333+
it('should handle array without items (default to any)', () => {
333334
const toolData = {
334335
name: 'arrayDefault',
335336
inputSchema: {
@@ -343,9 +344,72 @@ describe('McpHttpTransportBase', () => {
343344
expect(result.parameters[0]).toEqual(
344345
expect.objectContaining({
345346
type: 'array',
346-
items: {type: 'string'},
347347
}),
348348
);
349+
expect((result.parameters[0] as any).items).toBeUndefined();
350+
});
351+
352+
it('should convert schema with recursive types (nested arrays, arrays of maps)', () => {
353+
const toolData = {
354+
name: 'recursive_tool',
355+
inputSchema: {
356+
type: 'object',
357+
properties: {
358+
// List[List[str]]
359+
nested_array: {
360+
type: 'array',
361+
items: {
362+
type: 'array',
363+
items: {type: 'string'},
364+
},
365+
},
366+
// List[Dict[str, int]]
367+
array_of_maps: {
368+
type: 'array',
369+
items: {
370+
type: 'object',
371+
additionalProperties: {type: 'integer'},
372+
},
373+
},
374+
// Dict[str, List[int]]
375+
map_of_arrays: {
376+
type: 'object',
377+
additionalProperties: {
378+
type: 'array',
379+
items: {type: 'integer'},
380+
},
381+
},
382+
},
383+
},
384+
};
385+
386+
const result = transport.testConvertToolSchema(toolData);
387+
388+
// 1. Nested Array
389+
const pNested: any = result.parameters.find(
390+
p => p.name === 'nested_array',
391+
);
392+
expect(pNested.type).toBe('array');
393+
expect(pNested.items).toBeDefined();
394+
expect(pNested.items.type).toBe('array');
395+
expect(pNested.items.items).toBeDefined();
396+
expect(pNested.items.items.type).toBe('string');
397+
398+
// 2. Array of Maps
399+
const pArrMap: any = result.parameters.find(
400+
p => p.name === 'array_of_maps',
401+
);
402+
expect(pArrMap.type).toBe('array');
403+
expect(pArrMap.items).toBeDefined();
404+
expect(pArrMap.items.type).toBe('object');
405+
expect(pArrMap.items.additionalProperties.type).toBe('integer');
406+
407+
// 3. Map of Arrays
408+
const pMapArr: any = result.parameters.find(
409+
p => p.name === 'map_of_arrays',
410+
);
411+
expect(pMapArr.type).toBe('object');
412+
expect(pMapArr.additionalProperties.type).toBe('array');
349413
});
350414

351415
it('should handle partial auth metadata', () => {

packages/toolbox-core/test/test.protocol.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -199,13 +199,9 @@ describe('ZodParameterSchema', () => {
199199
});
200200
});
201201

202-
it('should invalidate an array parameter with missing items definition', () => {
202+
it('should validate an array parameter with missing items definition (defaults to any)', () => {
203203
const data = {name: 'testArray', description: 'An array', type: 'array'};
204-
expectParseFailure(ZodParameterSchema, data, errors => {
205-
expect(errors).toEqual(
206-
expect.arrayContaining([expect.stringMatching(/items: Required/i)]),
207-
);
208-
});
204+
expectParseSuccess(ZodParameterSchema, data);
209205
});
210206

211207
it('should invalidate if type is missing', () => {

0 commit comments

Comments
 (0)