diff --git a/src/utils/api.test.ts b/src/utils/api.test.ts index 721746b0..52f8ed4c 100644 --- a/src/utils/api.test.ts +++ b/src/utils/api.test.ts @@ -78,3 +78,28 @@ test('toolToAPISchema keeps skill required for SkillTool', async () => { required: ['skill'], }) }) + +test('toolToAPISchema removes extra required keys not in properties (MCP schema sanitization)', async () => { + const schema = await toolToAPISchema( + { + name: 'mcp__test__create_object', + inputSchema: z.strictObject({}), + inputJSONSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name', 'attributes'], + }, + prompt: async () => 'Create an object', + } as unknown as Tool, + { + getToolPermissionContext: async () => getEmptyToolPermissionContext(), + tools: [] as unknown as Tools, + agents: [], + }, + ) + + const inputSchema = (schema as { input_schema: { required?: string[] } }).input_schema + expect(inputSchema.required).toEqual(['name']) +}) diff --git a/src/utils/api.ts b/src/utils/api.ts index 9b66fd79..616b1544 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -111,11 +111,60 @@ function filterSwarmFieldsFromSchema( delete filteredProps[field] } filtered.properties = filteredProps + + // Keep `required` in sync after removing properties + if (Array.isArray(filtered.required)) { + filtered.required = filtered.required.filter( + (key: string) => key in filteredProps, + ) + } } return filtered } +/** + * Ensure `required` only lists keys present in `properties`. + * MCP servers may emit schemas where these are out of sync, causing + * API 400 errors ("Extra required key supplied"). + * Recurses into nested object schemas. + */ +function sanitizeSchemaRequired( + schema: Anthropic.Tool.InputSchema, +): Anthropic.Tool.InputSchema { + if (!schema || typeof schema !== 'object') { + return schema + } + + const result = { ...schema } + const props = result.properties as Record | undefined + + if (props && Array.isArray(result.required)) { + result.required = result.required.filter( + (key: string) => key in props, + ) + } + + // Recurse into nested object properties + if (props) { + const sanitizedProps = { ...props } + for (const [key, value] of Object.entries(sanitizedProps)) { + if ( + value && + typeof value === 'object' && + (value as Record).type === 'object' + ) { + sanitizedProps[key] = sanitizeSchemaRequired( + value as Anthropic.Tool.InputSchema, + ) + } + } + result.properties = sanitizedProps + } + + return result +} + export async function toolToAPISchema( tool: Tool, options: { @@ -156,7 +205,7 @@ export async function toolToAPISchema( // Use tool's JSON schema directly if provided, otherwise convert Zod schema let input_schema = ( 'inputJSONSchema' in tool && tool.inputJSONSchema - ? tool.inputJSONSchema + ? sanitizeSchemaRequired(tool.inputJSONSchema as Anthropic.Tool.InputSchema) : zodToJsonSchema(tool.inputSchema) ) as Anthropic.Tool.InputSchema