Skip to content

Commit 06fd929

Browse files
authored
fix: sanitize tool schema required fields (#570)
1 parent 4a8ae04 commit 06fd929

2 files changed

Lines changed: 124 additions & 3 deletions

File tree

npm/src/agent/ProbeAgent.js

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,77 @@ export function debugLogToolResults(toolResults) {
147147
}
148148
}
149149

150+
function isPlainJsonSchemaObject(value) {
151+
return value && typeof value === 'object' && !Array.isArray(value) && !value._def;
152+
}
153+
154+
function sanitizeRequiredFieldsInJsonSchema(schema) {
155+
if (!isPlainJsonSchemaObject(schema)) {
156+
return;
157+
}
158+
159+
if (Array.isArray(schema.required) && isPlainJsonSchemaObject(schema.properties)) {
160+
const propertyNames = new Set(Object.keys(schema.properties));
161+
const filteredRequired = schema.required.filter((name) => propertyNames.has(name));
162+
if (filteredRequired.length > 0) {
163+
schema.required = filteredRequired;
164+
} else {
165+
delete schema.required;
166+
}
167+
}
168+
169+
const visitSchemaMap = (schemaMap) => {
170+
if (!isPlainJsonSchemaObject(schemaMap)) return;
171+
for (const childSchema of Object.values(schemaMap)) {
172+
sanitizeRequiredFieldsInJsonSchema(childSchema);
173+
}
174+
};
175+
176+
visitSchemaMap(schema.properties);
177+
visitSchemaMap(schema.patternProperties);
178+
visitSchemaMap(schema.definitions);
179+
visitSchemaMap(schema.$defs);
180+
visitSchemaMap(schema.dependentSchemas);
181+
182+
if (isPlainJsonSchemaObject(schema.items)) {
183+
sanitizeRequiredFieldsInJsonSchema(schema.items);
184+
} else if (Array.isArray(schema.items)) {
185+
for (const itemSchema of schema.items) {
186+
sanitizeRequiredFieldsInJsonSchema(itemSchema);
187+
}
188+
}
189+
190+
for (const keyword of ['allOf', 'anyOf', 'oneOf']) {
191+
if (Array.isArray(schema[keyword])) {
192+
for (const childSchema of schema[keyword]) {
193+
sanitizeRequiredFieldsInJsonSchema(childSchema);
194+
}
195+
}
196+
}
197+
198+
if (isPlainJsonSchemaObject(schema.not)) {
199+
sanitizeRequiredFieldsInJsonSchema(schema.not);
200+
}
201+
202+
if (isPlainJsonSchemaObject(schema.additionalProperties)) {
203+
sanitizeRequiredFieldsInJsonSchema(schema.additionalProperties);
204+
}
205+
}
206+
207+
export function sanitizeToolInputSchema(schema) {
208+
if (!isPlainJsonSchemaObject(schema)) {
209+
return schema;
210+
}
211+
212+
try {
213+
const clonedSchema = JSON.parse(JSON.stringify(schema));
214+
sanitizeRequiredFieldsInJsonSchema(clonedSchema);
215+
return clonedSchema;
216+
} catch {
217+
return schema;
218+
}
219+
}
220+
150221
/**
151222
* ProbeAgent class to handle AI interactions with code search capabilities
152223
*/
@@ -1888,7 +1959,8 @@ export class ProbeAgent {
18881959
const wrapTool = (toolName, schema, description, executeFn) => {
18891960
// Auto-wrap plain JSON Schema objects with jsonSchema() for AI SDK 5 compatibility
18901961
// Zod schemas have a _def property; plain objects need wrapping
1891-
const resolvedSchema = schema && schema._def ? schema : jsonSchema(schema);
1962+
const sanitizedSchema = sanitizeToolInputSchema(schema);
1963+
const resolvedSchema = sanitizedSchema && sanitizedSchema._def ? sanitizedSchema : jsonSchema(sanitizedSchema);
18921964
return tool({
18931965
description,
18941966
inputSchema: resolvedSchema,
@@ -2090,8 +2162,9 @@ export class ProbeAgent {
20902162
for (const [name, mcpTool] of Object.entries(mcpTools)) {
20912163
// MCP tools have raw JSON Schema inputSchema that must be wrapped with jsonSchema()
20922164
// for the Vercel AI SDK. Without wrapping, asSchema() misidentifies them as Zod schemas.
2093-
const mcpSchema = mcpTool.inputSchema || mcpTool.parameters;
2094-
const wrappedSchema = mcpSchema && mcpSchema._def ? mcpSchema : jsonSchema(mcpSchema || { type: 'object', properties: {} });
2165+
const mcpSchema = mcpTool.inputSchema || mcpTool.parameters || { type: 'object', properties: {} };
2166+
const sanitizedSchema = sanitizeToolInputSchema(mcpSchema);
2167+
const wrappedSchema = sanitizedSchema && sanitizedSchema._def ? sanitizedSchema : jsonSchema(sanitizedSchema);
20952168
nativeTools[name] = tool({
20962169
description: mcpTool.description || `MCP tool: ${name}`,
20972170
inputSchema: wrappedSchema,

npm/tests/unit/mcp-message-history.test.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,54 @@ describe('MCP Tool Native Integration', () => {
273273

274274
});
275275

276+
test('should remove MCP required fields that are not defined in properties (issue #569)', () => {
277+
const jiraSchema = {
278+
type: 'object',
279+
properties: {
280+
jql: { type: 'string', description: 'Jira query' },
281+
options: {
282+
type: 'object',
283+
properties: {
284+
limit: { type: 'number' },
285+
includeDone: { type: 'boolean' }
286+
},
287+
required: ['limit', 'missingNested']
288+
},
289+
filters: {
290+
type: 'array',
291+
items: {
292+
type: 'object',
293+
properties: {
294+
field: { type: 'string' },
295+
value: { type: 'string' }
296+
},
297+
required: ['field', 'missingArrayItem']
298+
}
299+
}
300+
},
301+
required: ['jql', 'cloudId', 'options']
302+
};
303+
304+
mockMcpBridge.getToolNames = jest.fn(() => ['jira_search']);
305+
mockMcpBridge.getVercelTools = jest.fn(() => ({
306+
jira_search: {
307+
description: 'Search Jira issues',
308+
inputSchema: jiraSchema,
309+
execute: jest.fn(async () => 'results')
310+
}
311+
}));
312+
313+
const tools = agent._buildNativeTools({});
314+
315+
expect(tools).toHaveProperty('jira_search');
316+
const sanitizedSchema = tools.jira_search.inputSchema.jsonSchema;
317+
expect(sanitizedSchema.required).toEqual(['jql', 'options']);
318+
expect(sanitizedSchema.properties.options.required).toEqual(['limit']);
319+
expect(sanitizedSchema.properties.filters.items.required).toEqual(['field']);
320+
expect(jiraSchema.required).toEqual(['jql', 'cloudId', 'options']);
321+
expect(jiraSchema.properties.options.required).toEqual(['limit', 'missingNested']);
322+
});
323+
276324
test('should handle MCP tools with empty inputSchema', () => {
277325
mockMcpBridge.getToolNames = jest.fn(() => ['simple_tool']);
278326
mockMcpBridge.getVercelTools = jest.fn(() => ({

0 commit comments

Comments
 (0)