From 861cfe8a3c7ec61362eba1070f382e42fe33e179 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 12 Aug 2026 22:29:25 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=A9=20fix:=20Normalize=20Malformed=20M?= =?UTF-8?q?CP=20Required=20Schemas=20(#14771)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/api/src/mcp/__tests__/zod.spec.ts | 44 ++++++++++++++++++++++ packages/api/src/mcp/zod.ts | 26 +++++++++++++ 2 files changed, 70 insertions(+) diff --git a/packages/api/src/mcp/__tests__/zod.spec.ts b/packages/api/src/mcp/__tests__/zod.spec.ts index c614fb7b99..fa1780cd2a 100644 --- a/packages/api/src/mcp/__tests__/zod.spec.ts +++ b/packages/api/src/mcp/__tests__/zod.spec.ts @@ -2121,6 +2121,50 @@ describe('normalizeJsonSchema', () => { expect(result.additionalProperties).toEqual({ type: 'string', enum: ['val'] }); }); + it('should drop malformed required keywords recursively', () => { + const schema = { + type: 'object', + properties: { + required: { type: 'boolean' }, + config: { + type: 'object', + properties: { host: { type: 'string' } }, + required: { host: true }, + }, + }, + oneOf: [{ type: 'object', required: 'name' }], + items: { type: 'object', required: null }, + required: {}, + }; + + expect(normalizeJsonSchema(schema)).toEqual({ + type: 'object', + properties: { + required: { type: 'boolean' }, + config: { + type: 'object', + properties: { host: { type: 'string' } }, + }, + }, + oneOf: [{ type: 'object' }], + items: { type: 'object' }, + }); + }); + + it('should normalize required arrays to unique property names', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name', 42, null, 'age', 'name', { key: 'value' }], + }; + + expect(normalizeJsonSchema(schema).required).toEqual(['name', 'age']); + expect(normalizeJsonSchema({ type: 'object', required: [] }).required).toEqual([]); + }); + it('should handle null, undefined, and primitive inputs safely', () => { expect(normalizeJsonSchema(null as any)).toBeNull(); expect(normalizeJsonSchema(undefined as any)).toBeUndefined(); diff --git a/packages/api/src/mcp/zod.ts b/packages/api/src/mcp/zod.ts index 178255e39c..082b6b6c96 100644 --- a/packages/api/src/mcp/zod.ts +++ b/packages/api/src/mcp/zod.ts @@ -337,6 +337,7 @@ export function resolveJsonSchemaRefs>( * - Strips vendor extension fields (`x-*` prefixed keys, e.g. `x-google-enum-descriptions`) * - Strips `definitions` and `$`-prefixed schema keywords (`$defs`, `$schema`, * `$id`, `$comment`, ...) that may survive ref resolution + * - Drops malformed `required` values and filters arrays to property names * * Beyond LLM compatibility, dropping every `$`-prefixed keyword also makes the * output safe to persist: MongoDB rejects field names beginning with `$`, so a @@ -375,6 +376,23 @@ const SCHEMA_MAP_KEYWORDS = new Set([ /** Keywords whose value is an array of subschemas. */ const SCHEMA_LIST_KEYWORDS = new Set(['oneOf', 'anyOf', 'allOf', 'prefixItems']); +function normalizeRequired(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const required: string[] = []; + const seen = new Set(); + for (const entry of value) { + if (typeof entry !== 'string' || seen.has(entry)) { + continue; + } + seen.add(entry); + required.push(entry); + } + return required; +} + export function normalizeJsonSchema>(schema: T): T { if (!schema || typeof schema !== 'object') { return schema; @@ -416,6 +434,14 @@ export function normalizeJsonSchema>(schema: T continue; } + if (key === 'required') { + const required = normalizeRequired(value); + if (required) { + result[key] = required; + } + continue; + } + if ( SCHEMA_MAP_KEYWORDS.has(key) && value &&