Skip to content

Commit 04ca64f

Browse files
authored
Merge pull request #150 from QuantGeekDev/fix/34-nested-schema-description-validation
fix: validate descriptions on nested object fields in Zod schemas
2 parents 7add2f7 + c719374 commit 04ca64f

File tree

2 files changed

+130
-3
lines changed

2 files changed

+130
-3
lines changed

src/tools/BaseTool.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ export abstract class MCPTool<TInput extends Record<string, any> = any, TSchema
188188
const missingDescriptions: string[] = [];
189189

190190
Object.entries(shape).forEach(([key, fieldSchema]) => {
191-
const fieldInfo = this.extractFieldInfo(fieldSchema as z.ZodType);
191+
const fieldInfo = this.extractFieldInfo(fieldSchema as z.ZodType, key, missingDescriptions);
192192

193193
if (!fieldInfo.jsonSchema.description) {
194194
missingDescriptions.push(key);
@@ -216,7 +216,11 @@ export abstract class MCPTool<TInput extends Record<string, any> = any, TSchema
216216
};
217217
}
218218

219-
private extractFieldInfo(schema: z.ZodType): {
219+
private extractFieldInfo(
220+
schema: z.ZodType,
221+
fieldPath?: string,
222+
missingDescriptions?: string[]
223+
): {
220224
jsonSchema: any;
221225
isOptional: boolean;
222226
} {
@@ -279,7 +283,17 @@ export abstract class MCPTool<TInput extends Record<string, any> = any, TSchema
279283
const nestedRequired: string[] = [];
280284

281285
Object.entries(shape).forEach(([key, fieldSchema]) => {
282-
const nestedFieldInfo = this.extractFieldInfo(fieldSchema as z.ZodType);
286+
const nestedPath = fieldPath ? `${fieldPath}.${key}` : key;
287+
const nestedFieldInfo = this.extractFieldInfo(
288+
fieldSchema as z.ZodType,
289+
nestedPath,
290+
missingDescriptions
291+
);
292+
293+
if (missingDescriptions && !nestedFieldInfo.jsonSchema.description) {
294+
missingDescriptions.push(nestedPath);
295+
}
296+
283297
nestedProperties[key] = nestedFieldInfo.jsonSchema;
284298

285299
if (!nestedFieldInfo.isOptional) {
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, it, expect } from '@jest/globals';
2+
import { z } from 'zod';
3+
import { MCPTool } from '../../src/tools/BaseTool.js';
4+
5+
describe('Nested schema description validation', () => {
6+
it('should throw when nested object fields lack descriptions', () => {
7+
class NestedTool extends MCPTool {
8+
name = 'nested_tool';
9+
description = 'Tool with nested schema';
10+
schema = z.object({
11+
query: z.string().describe('Search query'),
12+
position: z.object({
13+
x: z.number(), // Missing description!
14+
y: z.number(), // Missing description!
15+
}).describe('Position object'),
16+
});
17+
18+
async execute(input: any) {
19+
return input;
20+
}
21+
}
22+
23+
const tool = new NestedTool();
24+
expect(() => tool.inputSchema).toThrow(/Missing descriptions/);
25+
expect(() => tool.inputSchema).toThrow(/position\.x/);
26+
expect(() => tool.inputSchema).toThrow(/position\.y/);
27+
});
28+
29+
it('should pass when all nested fields have descriptions', () => {
30+
class GoodNestedTool extends MCPTool {
31+
name = 'good_nested_tool';
32+
description = 'Tool with proper nested schema';
33+
schema = z.object({
34+
query: z.string().describe('Search query'),
35+
position: z.object({
36+
x: z.number().describe('X coordinate'),
37+
y: z.number().describe('Y coordinate'),
38+
}).describe('Position object'),
39+
});
40+
41+
async execute(input: any) {
42+
return input;
43+
}
44+
}
45+
46+
const tool = new GoodNestedTool();
47+
const schema = tool.inputSchema;
48+
expect(schema.type).toBe('object');
49+
expect((schema.properties as any).position.properties.x.description).toBe('X coordinate');
50+
expect((schema.properties as any).position.properties.y.description).toBe('Y coordinate');
51+
});
52+
53+
it('should validate deeply nested objects', () => {
54+
class DeepTool extends MCPTool {
55+
name = 'deep_tool';
56+
description = 'Tool with deeply nested schema';
57+
schema = z.object({
58+
config: z.object({
59+
nested: z.object({
60+
value: z.string(), // Missing description!
61+
}).describe('Nested config'),
62+
}).describe('Config object'),
63+
});
64+
65+
async execute(input: any) {
66+
return input;
67+
}
68+
}
69+
70+
const tool = new DeepTool();
71+
expect(() => tool.inputSchema).toThrow(/Missing descriptions/);
72+
expect(() => tool.inputSchema).toThrow(/config\.nested\.value/);
73+
});
74+
75+
it('should handle arrays with nested objects correctly', () => {
76+
class ArrayTool extends MCPTool {
77+
name = 'array_tool';
78+
description = 'Tool with array of objects';
79+
schema = z.object({
80+
items: z.array(z.object({
81+
name: z.string().describe('Item name'),
82+
})).describe('List of items'),
83+
});
84+
85+
async execute(input: any) {
86+
return input;
87+
}
88+
}
89+
90+
const tool = new ArrayTool();
91+
expect(() => tool.inputSchema).not.toThrow();
92+
});
93+
94+
it('should handle optional nested objects', () => {
95+
class OptionalNestedTool extends MCPTool {
96+
name = 'optional_nested_tool';
97+
description = 'Tool with optional nested schema';
98+
schema = z.object({
99+
filter: z.object({
100+
field: z.string().describe('Field name'),
101+
value: z.string().describe('Field value'),
102+
}).optional().describe('Optional filter'),
103+
});
104+
105+
async execute(input: any) {
106+
return input;
107+
}
108+
}
109+
110+
const tool = new OptionalNestedTool();
111+
expect(() => tool.inputSchema).not.toThrow();
112+
});
113+
});

0 commit comments

Comments
 (0)