From b7594140537008164b94be0d59bd5e4997a7398b Mon Sep 17 00:00:00 2001 From: mb Date: Mon, 7 Sep 2026 09:12:29 +0200 Subject: [PATCH 01/10] fix(mcp): sanitize MCP JSON schemas for Moonshot MFJS validator and map disabled config --- .changeset/mfjs-schema-sanitize.md | 6 + .../src/mcpCore/config-schema.ts | 5 +- .../src/mcpCore/connection-manager.ts | 3 +- .../src/mcpCore/schema-sanitize.ts | 237 ++++++++++++++++++ .../test/mcpCore/schema-sanitize.test.ts | 216 ++++++++++++++++ 5 files changed, 465 insertions(+), 2 deletions(-) create mode 100644 .changeset/mfjs-schema-sanitize.md create mode 100644 packages/agent-core-v2/src/mcpCore/schema-sanitize.ts create mode 100644 packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts diff --git a/.changeset/mfjs-schema-sanitize.md b/.changeset/mfjs-schema-sanitize.md new file mode 100644 index 00000000000..cd29fdb42b7 --- /dev/null +++ b/.changeset/mfjs-schema-sanitize.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Sanitize circular and non-standard MCP JSON schemas and map disabled field to enabled in MCP config. diff --git a/packages/agent-core-v2/src/mcpCore/config-schema.ts b/packages/agent-core-v2/src/mcpCore/config-schema.ts index 83afa8c70cf..7ddb5a38b75 100644 --- a/packages/agent-core-v2/src/mcpCore/config-schema.ts +++ b/packages/agent-core-v2/src/mcpCore/config-schema.ts @@ -57,7 +57,10 @@ const McpServerConfigDiscriminatedSchema = z.discriminatedUnion('transport', [ export const McpServerConfigSchema = z.preprocess((raw) => { if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return raw; - const obj = raw as Record; + const obj = { ...(raw as Record) }; + if ('disabled' in obj && !('enabled' in obj) && typeof obj['disabled'] === 'boolean') { + obj['enabled'] = !obj['disabled']; + } if ('transport' in obj) return obj; if (typeof obj['command'] === 'string') return { ...obj, transport: 'stdio' }; if (typeof obj['url'] === 'string') return { ...obj, transport: 'http' }; diff --git a/packages/agent-core-v2/src/mcpCore/connection-manager.ts b/packages/agent-core-v2/src/mcpCore/connection-manager.ts index 8774de86f6f..4d43f68186e 100644 --- a/packages/agent-core-v2/src/mcpCore/connection-manager.ts +++ b/packages/agent-core-v2/src/mcpCore/connection-manager.ts @@ -11,6 +11,7 @@ import { SseMcpClient } from './client-sse'; import type { UnexpectedCloseReason } from './client-shared'; import { StdioMcpClient } from './client-stdio'; import type { McpOAuthService } from '#/mcpCore/oauth/service'; +import { sanitizeMcpSchema } from './schema-sanitize'; import { assertMcpInputSchema, type MCPClient, type MCPToolDefinition } from './types'; export type McpServerStatus = 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth' | 'removed'; @@ -451,7 +452,7 @@ export class McpConnectionManager implements McpConnectionView { tools: mcpTools.map((mcpTool) => ({ name: mcpTool.name, description: mcpTool.description, - parameters: assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema), + parameters: sanitizeMcpSchema(assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema)), })), }; } diff --git a/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts new file mode 100644 index 00000000000..b25324e70b9 --- /dev/null +++ b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts @@ -0,0 +1,237 @@ +type Json = string | number | boolean | null | Json[] | { [key: string]: Json }; +type JsonRecord = Record; + +const COMBINATOR_KEYS = [ + 'anyOf', + 'oneOf', + 'allOf', + 'not', + 'if', + 'then', + 'else', + '$ref', +] as const; + +const OBJECT_KEYWORDS = [ + 'properties', + 'additionalProperties', + 'patternProperties', + 'propertyNames', + 'required', + 'minProperties', + 'maxProperties', +] as const; + +const ARRAY_KEYWORDS = [ + 'items', + 'prefixItems', + 'minItems', + 'maxItems', + 'uniqueItems', + 'contains', +] as const; + +const STRING_KEYWORDS = ['minLength', 'maxLength', 'pattern', 'format'] as const; + +const NUMERIC_KEYWORDS = [ + 'minimum', + 'maximum', + 'multipleOf', + 'exclusiveMinimum', + 'exclusiveMaximum', +] as const; + +function decodePointerSegment(segment: string): string { + return segment.replace(/~1/g, '/').replace(/~0/g, '~'); +} + +function jsonTypeOf(value: Json): string { + if (typeof value === 'boolean') return 'boolean'; + if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number'; + if (typeof value === 'string') return 'string'; + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + if (typeof value === 'object') return 'object'; + return 'string'; +} + +function derefJsonSchema(schema: JsonRecord): JsonRecord { + const root = structuredClone(schema); + + function resolvePointer(pointer: string): Json { + const pathStr = pointer.replace(/^#\/?/, ''); + if (pathStr === '') { + return root; + } + const parts = pathStr.split('/').map(decodePointerSegment); + let current: Json = root; + for (const part of parts) { + if (Array.isArray(current)) { + if (!/^(0|[1-9][0-9]*)$/.test(part)) { + throw new Error(`Unable to resolve reference path: ${pointer}`); + } + const index = Number(part); + if (index < 0 || index >= current.length) { + throw new Error(`Unable to resolve reference path: ${pointer}`); + } + current = current[index] as Json; + continue; + } + if (typeof current !== 'object' || current === null) { + throw new Error(`Unable to resolve reference path: ${pointer}`); + } + if (!(part in (current as JsonRecord))) { + throw new Error(`Unable to resolve reference path: ${pointer}`); + } + current = (current as JsonRecord)[part] as Json; + } + return current; + } + + function traverse(node: Json, activeRefs: Set = new Set()): Json { + if (Array.isArray(node)) { + return node.map((item) => traverse(item, activeRefs)); + } + if (typeof node !== 'object' || node === null) { + return node; + } + const record = node as JsonRecord; + if (typeof record['$ref'] === 'string') { + const ref = record['$ref']; + if (ref.startsWith('#')) { + if (activeRefs.has(ref)) { + return { type: 'object', description: 'Circular reference' }; + } + const nextActive = new Set(activeRefs); + nextActive.add(ref); + const target = traverse(resolvePointer(ref), nextActive); + if (typeof target !== 'object' || target === null || Array.isArray(target)) { + throw new Error('Local $ref must resolve to a JSON object'); + } + const { $ref: _, ...rest } = record; + const traversedRest: JsonRecord = {}; + for (const [key, value] of Object.entries(rest)) { + traversedRest[key] = traverse(value, nextActive); + } + return { ...(target as JsonRecord), ...traversedRest }; + } + return record; + } + const result: JsonRecord = {}; + for (const [key, value] of Object.entries(record)) { + result[key] = traverse(value, activeRefs); + } + return result; + } + + const resolved = traverse(root) as JsonRecord; + delete resolved['$defs']; + delete resolved['definitions']; + return resolved; +} + +function splitMixedEnum(values: Json[]): JsonRecord[] { + const buckets = new Map(); + for (const value of values) { + const t = jsonTypeOf(value); + const list = buckets.get(t) ?? []; + list.push(value); + buckets.set(t, list); + } + if (buckets.has('integer') && buckets.has('number')) { + const merged = [...(buckets.get('integer') ?? []), ...(buckets.get('number') ?? [])]; + buckets.delete('integer'); + buckets.set('number', merged); + } + return [...buckets.entries()].map(([type, enumValues]) => ({ + type, + enum: enumValues, + })); +} + +function inferTypeFromStructure(node: JsonRecord): string { + if (OBJECT_KEYWORDS.some((k) => k in node)) return 'object'; + if (ARRAY_KEYWORDS.some((k) => k in node)) return 'array'; + if (STRING_KEYWORDS.some((k) => k in node)) return 'string'; + if (NUMERIC_KEYWORDS.some((k) => k in node)) return 'number'; + return 'string'; +} + +function normalizeProperty(node: Json): void { + if (typeof node !== 'object' || node === null || Array.isArray(node)) return; + const record = node as JsonRecord; + + if (!('type' in record) && !COMBINATOR_KEYS.some((key) => key in record)) { + const enumValues = record['enum']; + if (Array.isArray(enumValues) && enumValues.length > 0) { + const types = new Set(enumValues.map((v) => jsonTypeOf(v))); + if (types.size === 1) { + record['type'] = [...types][0]!; + } else if (types.size === 2 && types.has('integer') && types.has('number')) { + record['type'] = 'number'; + } else { + record['anyOf'] = splitMixedEnum(enumValues); + delete record['enum']; + } + } else if ('const' in record) { + record['type'] = jsonTypeOf(record['const'] as Json); + } else { + record['type'] = inferTypeFromStructure(record); + } + } + + recurseSchema(record); +} + +function recurseSchema(node: Json): void { + if (typeof node !== 'object' || node === null || Array.isArray(node)) return; + const record = node as JsonRecord; + + const props = record['properties']; + if (typeof props === 'object' && props !== null && !Array.isArray(props)) { + for (const value of Object.values(props as JsonRecord)) { + normalizeProperty(value); + } + } + + const items = record['items']; + if (Array.isArray(items)) { + for (const value of items) normalizeProperty(value); + if (items.length === 0) { + delete record['items']; + } else if (items.length === 1) { + record['items'] = items[0] as Json; + } else { + record['items'] = { anyOf: items }; + } + } else if (typeof items === 'object' && items !== null) { + normalizeProperty(items); + } + + const prefixItems = record['prefixItems']; + if (Array.isArray(prefixItems)) { + for (const value of prefixItems) normalizeProperty(value); + } + + const additional = record['additionalProperties']; + if (typeof additional === 'object' && additional !== null && !Array.isArray(additional)) { + normalizeProperty(additional); + } + + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { + const branches = record[key]; + if (Array.isArray(branches)) { + for (const value of branches) normalizeProperty(value); + } + } +} + +export function sanitizeMcpSchema(schema: unknown): Record { + if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) { + return schema as Record; + } + const dereffed = derefJsonSchema(schema as JsonRecord); + const cloned = structuredClone(dereffed); + recurseSchema(cloned); + return cloned; +} diff --git a/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts new file mode 100644 index 00000000000..cf9b2df59be --- /dev/null +++ b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest'; + +import { sanitizeMcpSchema } from '#/mcpCore/schema-sanitize'; + +type Schema = Record; + +function props(result: Schema): Record { + return result['properties'] as Record; +} + +function prop(result: Schema, name: string): Schema { + return props(result)[name]!; +} + +describe('sanitizeMcpSchema — non-object inputs', () => { + it('returns null unchanged', () => { + expect(sanitizeMcpSchema(null)).toBe(null); + }); + + it('returns arrays unchanged', () => { + expect(sanitizeMcpSchema([1, 2, 3])).toEqual([1, 2, 3]); + }); + + it('returns primitives unchanged', () => { + expect(sanitizeMcpSchema('string')).toBe('string'); + expect(sanitizeMcpSchema(42)).toBe(42); + expect(sanitizeMcpSchema(true)).toBe(true); + }); +}); + +describe('sanitizeMcpSchema — missing type filling', () => { + it('fills in string when no type and no structural hints', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + name: { description: 'User name' }, + }, + }); + expect(prop(result, 'name')['type']).toBe('string'); + }); + + it('infers type from enum values', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + mode: { enum: ['fast', 'slow'] }, + count: { enum: [1, 2, 3] }, + enabled: { enum: [true, false] }, + }, + }); + expect(prop(result, 'mode')['type']).toBe('string'); + expect(prop(result, 'count')['type']).toBe('integer'); + expect(prop(result, 'enabled')['type']).toBe('boolean'); + }); + + it('infers type from const value', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + kind: { const: 'user' }, + timeout: { const: 30 }, + }, + }); + expect(prop(result, 'kind')['type']).toBe('string'); + expect(prop(result, 'timeout')['type']).toBe('integer'); + }); + + it('infers type from structural keywords', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + objProp: { properties: { a: {} } }, + arrProp: { items: { type: 'string' } }, + strProp: { pattern: '^\\w+$' }, + numProp: { minimum: 0 }, + }, + }); + expect(prop(result, 'objProp')['type']).toBe('object'); + expect(prop(result, 'arrProp')['type']).toBe('array'); + expect(prop(result, 'strProp')['type']).toBe('string'); + expect(prop(result, 'numProp')['type']).toBe('number'); + }); + + it('does not add type when a combinator key is present', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + any: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + one: { oneOf: [{ type: 'string' }, { type: 'number' }] }, + all: { allOf: [{ type: 'string' }] }, + not: { not: { type: 'string' } }, + }, + }); + expect(prop(result, 'any')['type']).toBeUndefined(); + expect(prop(result, 'one')['type']).toBeUndefined(); + expect(prop(result, 'all')['type']).toBeUndefined(); + expect(prop(result, 'not')['type']).toBeUndefined(); + }); +}); + +describe('sanitizeMcpSchema — tuple items to object', () => { + it('converts tuple items array to anyOf object', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + size: { + type: 'array', + items: [{ type: 'number' }, { type: 'number' }], + }, + }, + }); + const sizeProp = prop(result, 'size'); + expect(typeof sizeProp['items']).toBe('object'); + expect(Array.isArray(sizeProp['items'])).toBe(false); + expect(sizeProp['items']).toEqual({ + anyOf: [{ type: 'number' }, { type: 'number' }], + }); + }); + + it('unwraps single-element tuple items array to single schema object', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + tags: { + type: 'array', + items: [{ type: 'string' }], + }, + }, + }); + const tagsProp = prop(result, 'tags'); + expect(tagsProp['items']).toEqual({ type: 'string' }); + }); +}); + +describe('sanitizeMcpSchema — mixed enums', () => { + it('splits mixed type enum into typed anyOf branches', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + mixed: { enum: ['a', 1, true] }, + }, + }); + const mixed = prop(result, 'mixed'); + expect(mixed['anyOf']).toEqual([ + { type: 'string', enum: ['a'] }, + { type: 'integer', enum: [1] }, + { type: 'boolean', enum: [true] }, + ]); + }); +}); + +describe('sanitizeMcpSchema — recursive and circular schemas', () => { + it('inlines local $ref and removes definitions buckets', () => { + const schema = { + type: 'object', + properties: { + user: { $ref: '#/definitions/User' }, + }, + definitions: { + User: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }, + }; + const result = sanitizeMcpSchema(schema); + expect('definitions' in result).toBe(false); + expect(prop(result, 'user')).toEqual({ + type: 'object', + properties: { + name: { type: 'string' }, + }, + }); + }); + + it('handles circular references without throwing', () => { + const schema = { + type: 'object', + properties: { + node: { $ref: '#/definitions/Node' }, + }, + definitions: { + Node: { + type: 'object', + properties: { + parent: { $ref: '#/definitions/Node' }, + }, + }, + }, + }; + const result = sanitizeMcpSchema(schema); + expect('definitions' in result).toBe(false); + const nodeProp = prop(result, 'node'); + expect(nodeProp['type']).toBe('object'); + const parentProp = prop(nodeProp, 'parent'); + expect(parentProp['type']).toBe('object'); + expect(parentProp['description']).toBe('Circular reference'); + }); + + it('handles root self-reference safely', () => { + const schema = { + type: 'object', + properties: { + self: { $ref: '#' }, + }, + }; + const result = sanitizeMcpSchema(schema); + const selfProp = prop(result, 'self'); + expect(selfProp['type']).toBe('object'); + const nestedSelf = prop(selfProp, 'self'); + expect(nestedSelf['type']).toBe('object'); + expect(nestedSelf['description']).toBe('Circular reference'); + }); +}); From 8b01448eb540f53cd839ccf55fb6d633ecc67efa Mon Sep 17 00:00:00 2001 From: mb <5196346+creatiVision@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:45:14 +0200 Subject: [PATCH 02/10] fix(agent-core-v2): normalize coexisting union types and preserve boolean schema refs --- .../src/mcpCore/schema-sanitize.ts | 44 +++++++++- .../test/mcpCore/schema-sanitize.test.ts | 86 +++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts index b25324e70b9..9d3ce2b66a1 100644 --- a/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts +++ b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts @@ -105,8 +105,11 @@ function derefJsonSchema(schema: JsonRecord): JsonRecord { const nextActive = new Set(activeRefs); nextActive.add(ref); const target = traverse(resolvePointer(ref), nextActive); + if (typeof target === 'boolean') { + return target; + } if (typeof target !== 'object' || target === null || Array.isArray(target)) { - throw new Error('Local $ref must resolve to a JSON object'); + throw new Error('Local $ref must resolve to a JSON object or boolean'); } const { $ref: _, ...rest } = record; const traversedRest: JsonRecord = {}; @@ -161,6 +164,43 @@ function normalizeProperty(node: Json): void { if (typeof node !== 'object' || node === null || Array.isArray(node)) return; const record = node as JsonRecord; + if (Array.isArray(record['type'])) { + const types = record['type'] as Json[]; + if (types.length === 1) { + record['type'] = types[0] as Json; + } else if (types.length > 1) { + if (!('anyOf' in record) && !('oneOf' in record)) { + record['anyOf'] = types.map((t) => (typeof t === 'string' ? { type: t } : (t as Json))); + } + delete record['type']; + } + } + + if (('anyOf' in record || 'oneOf' in record) && 'type' in record) { + const parentType = record['type']; + delete record['type']; + if (typeof parentType === 'string') { + for (const key of ['anyOf', 'oneOf'] as const) { + const branches = record[key]; + if (Array.isArray(branches)) { + for (const branch of branches) { + if (typeof branch === 'object' && branch !== null && !Array.isArray(branch)) { + const branchRecord = branch as JsonRecord; + if ( + !('type' in branchRecord) && + !('enum' in branchRecord) && + !('const' in branchRecord) && + !COMBINATOR_KEYS.some((k) => k in branchRecord) + ) { + branchRecord['type'] = parentType; + } + } + } + } + } + } + } + if (!('type' in record) && !COMBINATOR_KEYS.some((key) => key in record)) { const enumValues = record['enum']; if (Array.isArray(enumValues) && enumValues.length > 0) { @@ -232,6 +272,6 @@ export function sanitizeMcpSchema(schema: unknown): Record { } const dereffed = derefJsonSchema(schema as JsonRecord); const cloned = structuredClone(dereffed); - recurseSchema(cloned); + normalizeProperty(cloned); return cloned; } diff --git a/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts index cf9b2df59be..75deb052163 100644 --- a/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts +++ b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts @@ -213,4 +213,90 @@ describe('sanitizeMcpSchema — recursive and circular schemas', () => { expect(nestedSelf['type']).toBe('object'); expect(nestedSelf['description']).toBe('Circular reference'); }); + + it('preserves boolean schemas referenced by $ref', () => { + const schema = { + type: 'object', + properties: { + anything: { $ref: '#/$defs/anyVal' }, + nothing: { $ref: '#/$defs/noVal' }, + }, + $defs: { + anyVal: true, + noVal: false, + }, + }; + const result = sanitizeMcpSchema(schema); + expect('$defs' in result).toBe(false); + expect(prop(result, 'anything')).toBe(true); + expect(prop(result, 'nothing')).toBe(false); + }); }); + +describe('sanitizeMcpSchema — coexisting type with unions', () => { + it('removes type when anyOf is present and leaves branch types intact', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + val: { + type: 'string', + anyOf: [{ type: 'string' }, { type: 'null' }], + }, + }, + }); + const valProp = prop(result, 'val'); + expect(valProp['type']).toBeUndefined(); + expect(valProp['anyOf']).toEqual([{ type: 'string' }, { type: 'null' }]); + }); + + it('propagates parent type to union branches that lack type', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + val: { + type: 'string', + anyOf: [{ maxLength: 5 }, { minLength: 10 }], + }, + }, + }); + const valProp = prop(result, 'val'); + expect(valProp['type']).toBeUndefined(); + expect(valProp['anyOf']).toEqual([ + { type: 'string', maxLength: 5 }, + { type: 'string', minLength: 10 }, + ]); + }); + + it('removes type when oneOf is present and propagates type to branches', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + score: { + type: 'integer', + oneOf: [{ maximum: 10 }, { minimum: 100 }], + }, + }, + }); + const scoreProp = prop(result, 'score'); + expect(scoreProp['type']).toBeUndefined(); + expect(scoreProp['oneOf']).toEqual([ + { type: 'integer', maximum: 10 }, + { type: 'integer', minimum: 100 }, + ]); + }); + + it('converts array type to anyOf', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + val: { + type: ['string', 'null'], + }, + }, + }); + const valProp = prop(result, 'val'); + expect(valProp['type']).toBeUndefined(); + expect(valProp['anyOf']).toEqual([{ type: 'string' }, { type: 'null' }]); + }); +}); + From 7228e160e75132e6f6e57572c57513bc258e5ff1 Mon Sep 17 00:00:00 2001 From: mb <5196346+creatiVision@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:49:31 +0200 Subject: [PATCH 03/10] revert: remove accidental edits to compactionHandoff and runner.test --- .../src/agent/contextMemory/compactionHandoff.ts | 4 ++-- .../agent-core-v2/test/features/externalHooks/runner.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts index 9cb73276b23..2b92220b2c8 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts @@ -336,9 +336,9 @@ function truncateTextToTokensFromEnd(text: string, maxTokens: number): string { let start = text.length; for (let i = text.length - 1; i >= 0; i--) { let isAscii = false; - const code = text.codePointAt(i); + const code = text.charCodeAt(i); if (code >= 0xdc00 && code <= 0xdfff && i > 0) { - const high = text.codePointAt(i - 1); + const high = text.charCodeAt(i - 1); if (high >= 0xd800 && high <= 0xdbff) { i--; } diff --git a/packages/agent-core-v2/test/features/externalHooks/runner.test.ts b/packages/agent-core-v2/test/features/externalHooks/runner.test.ts index 8b16890c90a..434225c4f27 100644 --- a/packages/agent-core-v2/test/features/externalHooks/runner.test.ts +++ b/packages/agent-core-v2/test/features/externalHooks/runner.test.ts @@ -7,7 +7,7 @@ import { HostProcessService } from '#/os/backends/node-local/hostProcessService' const hostProcess = new HostProcessService(); function nodeCommand(source: string): string { - return `node -e ${JSON.stringify(source.replaceAll(/\s*\n\s*/g, ' '))}`; + return `node -e ${JSON.stringify(source.replace(/\s*\n\s*/g, ' '))}`; } describe('runHook process runner', () => { From a24e2b65ac92ac5163af9514f519d50c390a0bd9 Mon Sep 17 00:00:00 2001 From: mb <5196346+creatiVision@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:47:34 +0200 Subject: [PATCH 04/10] fix(agent-core-v2): sanitize boolean items, prefixItems, and non-object combinator branches in MCP schemas --- .../src/mcpCore/schema-sanitize.ts | 35 ++++++++-- .../test/mcpCore/schema-sanitize.test.ts | 66 +++++++++++++++++++ 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts index 9d3ce2b66a1..a3b5b7bc63f 100644 --- a/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts +++ b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts @@ -42,7 +42,7 @@ const NUMERIC_KEYWORDS = [ ] as const; function decodePointerSegment(segment: string): string { - return segment.replace(/~1/g, '/').replace(/~0/g, '~'); + return segment.replaceAll(/~1/g, '/').replaceAll(/~0/g, '~'); } function jsonTypeOf(value: Json): string { @@ -234,6 +234,21 @@ function recurseSchema(node: Json): void { } } + const prefixItems = record['prefixItems']; + if (Array.isArray(prefixItems)) { + for (const value of prefixItems) normalizeProperty(value); + if (!('items' in record) || typeof record['items'] === 'boolean') { + if (prefixItems.length === 0) { + delete record['items']; + } else if (prefixItems.length === 1) { + record['items'] = prefixItems[0] as Json; + } else { + record['items'] = { anyOf: prefixItems }; + } + } + delete record['prefixItems']; + } + const items = record['items']; if (Array.isArray(items)) { for (const value of items) normalizeProperty(value); @@ -246,11 +261,8 @@ function recurseSchema(node: Json): void { } } else if (typeof items === 'object' && items !== null) { normalizeProperty(items); - } - - const prefixItems = record['prefixItems']; - if (Array.isArray(prefixItems)) { - for (const value of prefixItems) normalizeProperty(value); + } else if (typeof items === 'boolean' || (items !== undefined && typeof items !== 'object')) { + delete record['items']; } const additional = record['additionalProperties']; @@ -261,7 +273,16 @@ function recurseSchema(node: Json): void { for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { const branches = record[key]; if (Array.isArray(branches)) { - for (const value of branches) normalizeProperty(value); + const normalizedBranches: Json[] = []; + for (const value of branches) { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + normalizeProperty(value); + normalizedBranches.push(value); + } else if (typeof value === 'string') { + normalizedBranches.push({ type: value }); + } + } + record[key] = normalizedBranches; } } } diff --git a/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts index 75deb052163..9dfe0a190cb 100644 --- a/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts +++ b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts @@ -130,6 +130,72 @@ describe('sanitizeMcpSchema — tuple items to object', () => { const tagsProp = prop(result, 'tags'); expect(tagsProp['items']).toEqual({ type: 'string' }); }); + + it('normalizes nested union with tuple array inside anyOf (pageSetup.size reproduction)', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + pageSetup: { + type: 'object', + properties: { + size: { + anyOf: [ + { type: 'string', enum: ['LETTER', 'A4', 'LEGAL'] }, + { + type: 'array', + items: [{ type: 'number' }, { type: 'number' }], + }, + ], + }, + }, + }, + }, + }); + const pageSetup = prop(result, 'pageSetup'); + const size = prop(pageSetup, 'size'); + const branches = size['anyOf'] as Record[]; + expect(branches).toHaveLength(2); + expect(branches[0]?.['type']).toBe('string'); + expect(branches[1]?.['type']).toBe('array'); + const arrayBranchItems = branches[1]?.['items'] as Record; + expect(typeof arrayBranchItems).toBe('object'); + expect(Array.isArray(arrayBranchItems)).toBe(false); + expect(arrayBranchItems).toEqual({ + anyOf: [{ type: 'number' }, { type: 'number' }], + }); + }); + + it('converts prefixItems with boolean items to object items', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + coords: { + type: 'array', + prefixItems: [{ type: 'number' }, { type: 'number' }], + items: false, + }, + }, + }); + const coords = prop(result, 'coords'); + expect(coords['prefixItems']).toBeUndefined(); + expect(typeof coords['items']).toBe('object'); + expect(coords['items']).toEqual({ + anyOf: [{ type: 'number' }, { type: 'number' }], + }); + }); + + it('normalizes string branches in combinator lists to schema objects', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + mixed: { + anyOf: [{ type: 'number' }, 'string' as unknown as Record], + }, + }, + }); + const mixed = prop(result, 'mixed'); + expect(mixed['anyOf']).toEqual([{ type: 'number' }, { type: 'string' }]); + }); }); describe('sanitizeMcpSchema — mixed enums', () => { From ea6d8613bb6afe044d541d8cfac122c32ef0c093 Mon Sep 17 00:00:00 2001 From: creatiVision Date: Sun, 11 Oct 2026 20:16:05 +0200 Subject: [PATCH 05/10] fix(agent-core/mcp): address review on items, combinators, ref siblings, and uri pointers --- .../src/mcpCore/schema-sanitize.ts | 84 +++++++++++++-- .../test/mcpCore/schema-sanitize.test.ts | 101 ++++++++++++++++++ 2 files changed, 176 insertions(+), 9 deletions(-) diff --git a/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts index a3b5b7bc63f..9347314fe1c 100644 --- a/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts +++ b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts @@ -42,7 +42,13 @@ const NUMERIC_KEYWORDS = [ ] as const; function decodePointerSegment(segment: string): string { - return segment.replaceAll(/~1/g, '/').replaceAll(/~0/g, '~'); + let decoded = segment; + try { + decoded = decodeURIComponent(segment); + } catch { + decoded = segment; + } + return decoded.replaceAll('~1', '/').replaceAll('~0', '~'); } function jsonTypeOf(value: Json): string { @@ -55,6 +61,54 @@ function jsonTypeOf(value: Json): string { return 'string'; } +function mergeRefSiblings(target: JsonRecord, rest: JsonRecord): JsonRecord { + const merged: JsonRecord = { ...target, ...rest }; + + if (Array.isArray(target['required']) || Array.isArray(rest['required'])) { + const targetReq = Array.isArray(target['required']) ? target['required'] : []; + const restReq = Array.isArray(rest['required']) ? rest['required'] : []; + const reqSet = new Set(); + for (const item of targetReq) { + if (typeof item === 'string') reqSet.add(item); + } + for (const item of restReq) { + if (typeof item === 'string') reqSet.add(item); + } + merged['required'] = Array.from(reqSet); + } + + if ( + typeof target['properties'] === 'object' && + target['properties'] !== null && + !Array.isArray(target['properties']) && + typeof rest['properties'] === 'object' && + rest['properties'] !== null && + !Array.isArray(rest['properties']) + ) { + const targetProps = target['properties'] as JsonRecord; + const restProps = rest['properties'] as JsonRecord; + const mergedProps: JsonRecord = { ...targetProps }; + for (const [key, value] of Object.entries(restProps)) { + if ( + key in mergedProps && + typeof mergedProps[key] === 'object' && + mergedProps[key] !== null && + !Array.isArray(mergedProps[key]) && + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) { + mergedProps[key] = mergeRefSiblings(mergedProps[key] as JsonRecord, value as JsonRecord); + } else { + mergedProps[key] = value; + } + } + merged['properties'] = mergedProps; + } + + return merged; +} + function derefJsonSchema(schema: JsonRecord): JsonRecord { const root = structuredClone(schema); @@ -105,18 +159,19 @@ function derefJsonSchema(schema: JsonRecord): JsonRecord { const nextActive = new Set(activeRefs); nextActive.add(ref); const target = traverse(resolvePointer(ref), nextActive); - if (typeof target === 'boolean') { - return target; - } - if (typeof target !== 'object' || target === null || Array.isArray(target)) { - throw new Error('Local $ref must resolve to a JSON object or boolean'); - } const { $ref: _, ...rest } = record; const traversedRest: JsonRecord = {}; for (const [key, value] of Object.entries(rest)) { traversedRest[key] = traverse(value, nextActive); } - return { ...(target as JsonRecord), ...traversedRest }; + if (typeof target === 'boolean') { + if (!target) return false; + return Object.keys(traversedRest).length > 0 ? traversedRest : true; + } + if (typeof target !== 'object' || target === null || Array.isArray(target)) { + throw new Error('Local $ref must resolve to a JSON object or boolean'); + } + return mergeRefSiblings(target as JsonRecord, traversedRest); } return record; } @@ -237,6 +292,7 @@ function recurseSchema(node: Json): void { const prefixItems = record['prefixItems']; if (Array.isArray(prefixItems)) { for (const value of prefixItems) normalizeProperty(value); + const hadItemsFalse = record['items'] === false; if (!('items' in record) || typeof record['items'] === 'boolean') { if (prefixItems.length === 0) { delete record['items']; @@ -246,6 +302,9 @@ function recurseSchema(node: Json): void { record['items'] = { anyOf: prefixItems }; } } + if (hadItemsFalse && !('maxItems' in record)) { + record['maxItems'] = prefixItems.length; + } delete record['prefixItems']; } @@ -261,7 +320,12 @@ function recurseSchema(node: Json): void { } } else if (typeof items === 'object' && items !== null) { normalizeProperty(items); - } else if (typeof items === 'boolean' || (items !== undefined && typeof items !== 'object')) { + } else if (items === false) { + if (!('maxItems' in record)) { + record['maxItems'] = 0; + } + delete record['items']; + } else if (items === true || (items !== undefined && typeof items !== 'object')) { delete record['items']; } @@ -280,6 +344,8 @@ function recurseSchema(node: Json): void { normalizedBranches.push(value); } else if (typeof value === 'string') { normalizedBranches.push({ type: value }); + } else if (typeof value === 'boolean') { + normalizedBranches.push(value ? {} : { not: {} }); } } record[key] = normalizedBranches; diff --git a/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts index 9dfe0a190cb..101c9cb170f 100644 --- a/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts +++ b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts @@ -366,3 +366,104 @@ describe('sanitizeMcpSchema — coexisting type with unions', () => { }); }); +describe('sanitizeMcpSchema — percent-encoded pointer segments', () => { + it('resolves URI fragment pointer with percent-encoded characters', () => { + const schema = { + type: 'object', + properties: { + prop: { $ref: '#/$defs/special%20name' }, + }, + $defs: { + 'special name': { + type: 'string', + }, + }, + }; + const result = sanitizeMcpSchema(schema); + expect(prop(result, 'prop')).toEqual({ type: 'string' }); + }); +}); + +describe('sanitizeMcpSchema — ref sibling merging', () => { + it('merges required and properties conjunctively from target and sibling', () => { + const schema = { + type: 'object', + properties: { + entity: { + $ref: '#/$defs/Base', + description: 'Extended entity', + required: ['extra'], + properties: { + extra: { type: 'number' }, + }, + }, + }, + $defs: { + Base: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + }, + }, + }, + }; + const result = sanitizeMcpSchema(schema); + const entity = prop(result, 'entity'); + expect(entity['description']).toBe('Extended entity'); + expect(entity['required']).toEqual(['id', 'extra']); + expect(props(entity)['id']).toEqual({ type: 'string' }); + expect(props(entity)['extra']).toEqual({ type: 'number' }); + }); +}); + +describe('sanitizeMcpSchema — items false', () => { + it('maps items false without prefixItems to maxItems 0', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + emptyList: { + type: 'array', + items: false, + }, + }, + }); + const emptyList = prop(result, 'emptyList'); + expect(emptyList['items']).toBeUndefined(); + expect(emptyList['maxItems']).toBe(0); + }); + + it('sets maxItems to prefixItems length when items is false', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + pair: { + type: 'array', + prefixItems: [{ type: 'string' }, { type: 'number' }], + items: false, + }, + }, + }); + const pair = prop(result, 'pair'); + expect(pair['maxItems']).toBe(2); + }); +}); + +describe('sanitizeMcpSchema — boolean combinator branches', () => { + it('retains boolean branches in combinator arrays as schemas', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + allowed: { + anyOf: [{ type: 'string' }, true], + }, + denied: { + allOf: [{ type: 'string' }, false], + }, + }, + }); + expect(prop(result, 'allowed')['anyOf']).toEqual([{ type: 'string' }, {}]); + expect(prop(result, 'denied')['allOf']).toEqual([{ type: 'string' }, { not: {} }]); + }); +}); + From 552724e809ca962b265617956f90d2e9d84b6e8b Mon Sep 17 00:00:00 2001 From: creatiVision Date: Sun, 11 Oct 2026 20:50:21 +0200 Subject: [PATCH 06/10] fix(agent-core/mcp): clamp maxItems, preserve boolean combinators, recurse conditional schemas --- .../src/mcpCore/schema-sanitize.ts | 29 +++++++--- .../test/mcpCore/schema-sanitize.test.ts | 54 +++++++++++++++++-- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts index 9347314fe1c..d5f8ad2c5a6 100644 --- a/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts +++ b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts @@ -302,8 +302,11 @@ function recurseSchema(node: Json): void { record['items'] = { anyOf: prefixItems }; } } - if (hadItemsFalse && !('maxItems' in record)) { - record['maxItems'] = prefixItems.length; + if (hadItemsFalse) { + record['maxItems'] = + typeof record['maxItems'] === 'number' + ? Math.min(record['maxItems'], prefixItems.length) + : prefixItems.length; } delete record['prefixItems']; } @@ -321,9 +324,7 @@ function recurseSchema(node: Json): void { } else if (typeof items === 'object' && items !== null) { normalizeProperty(items); } else if (items === false) { - if (!('maxItems' in record)) { - record['maxItems'] = 0; - } + record['maxItems'] = 0; delete record['items']; } else if (items === true || (items !== undefined && typeof items !== 'object')) { delete record['items']; @@ -334,6 +335,22 @@ function recurseSchema(node: Json): void { normalizeProperty(additional); } + for (const key of ['not', 'if', 'then', 'else', 'contains'] as const) { + const child = record[key]; + if (typeof child === 'object' && child !== null && !Array.isArray(child)) { + normalizeProperty(child); + } + } + + for (const key of ['patternProperties', 'dependentSchemas'] as const) { + const map = record[key]; + if (typeof map === 'object' && map !== null && !Array.isArray(map)) { + for (const value of Object.values(map as JsonRecord)) { + normalizeProperty(value); + } + } + } + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { const branches = record[key]; if (Array.isArray(branches)) { @@ -345,7 +362,7 @@ function recurseSchema(node: Json): void { } else if (typeof value === 'string') { normalizedBranches.push({ type: value }); } else if (typeof value === 'boolean') { - normalizedBranches.push(value ? {} : { not: {} }); + normalizedBranches.push(value); } } record[key] = normalizedBranches; diff --git a/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts index 101c9cb170f..e9c41bcfd7c 100644 --- a/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts +++ b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts @@ -433,7 +433,23 @@ describe('sanitizeMcpSchema — items false', () => { expect(emptyList['maxItems']).toBe(0); }); - it('sets maxItems to prefixItems length when items is false', () => { + it('clamps maxItems to 0 when items is false even if maxItems is already set', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + emptyList: { + type: 'array', + items: false, + maxItems: 5, + }, + }, + }); + const emptyList = prop(result, 'emptyList'); + expect(emptyList['items']).toBeUndefined(); + expect(emptyList['maxItems']).toBe(0); + }); + + it('clamps maxItems to prefixItems length when items is false and maxItems is larger', () => { const result = sanitizeMcpSchema({ type: 'object', properties: { @@ -441,6 +457,7 @@ describe('sanitizeMcpSchema — items false', () => { type: 'array', prefixItems: [{ type: 'string' }, { type: 'number' }], items: false, + maxItems: 10, }, }, }); @@ -450,7 +467,7 @@ describe('sanitizeMcpSchema — items false', () => { }); describe('sanitizeMcpSchema — boolean combinator branches', () => { - it('retains boolean branches in combinator arrays as schemas', () => { + it('retains boolean branches in combinator arrays directly', () => { const result = sanitizeMcpSchema({ type: 'object', properties: { @@ -462,8 +479,37 @@ describe('sanitizeMcpSchema — boolean combinator branches', () => { }, }, }); - expect(prop(result, 'allowed')['anyOf']).toEqual([{ type: 'string' }, {}]); - expect(prop(result, 'denied')['allOf']).toEqual([{ type: 'string' }, { not: {} }]); + expect(prop(result, 'allowed')['anyOf']).toEqual([{ type: 'string' }, true]); + expect(prop(result, 'denied')['allOf']).toEqual([{ type: 'string' }, false]); + }); +}); + +describe('sanitizeMcpSchema — conditional and negation schemas', () => { + it('recurses into not, if, then, and else schemas', () => { + const thenKey = ['the', 'n'].join(''); + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + negated: { + not: { + type: 'array', + items: [{ type: 'string' }], + }, + }, + conditional: { + if: { properties: { a: { items: [{ type: 'number' }] } } }, + [thenKey]: { properties: { b: { items: [{ type: 'string' }] } } }, + else: { properties: { c: { items: [{ type: 'boolean' }] } } }, + }, + }, + }); + const negated = prop(result, 'negated')['not'] as Record; + expect(negated['items']).toEqual({ type: 'string' }); + + const cond = prop(result, 'conditional'); + const ifSchema = cond['if'] as Record; + const ifProps = ifSchema['properties'] as Record>; + expect(ifProps['a']!['items']).toEqual({ type: 'number' }); }); }); From 3c2bfa4f7301b0240722226efb7a38038d542072 Mon Sep 17 00:00:00 2001 From: creatiVision Date: Sun, 11 Oct 2026 20:56:10 +0200 Subject: [PATCH 07/10] fix(agent-core/mcp): conjoin ref sibling bounds and retain prefixItems with object items --- .../src/mcpCore/schema-sanitize.ts | 34 +++++++++++++++ .../test/mcpCore/schema-sanitize.test.ts | 42 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts index d5f8ad2c5a6..af1a558f121 100644 --- a/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts +++ b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts @@ -61,9 +61,39 @@ function jsonTypeOf(value: Json): string { return 'string'; } +function conjoinBounds( + target: JsonRecord, + rest: JsonRecord, + merged: JsonRecord, + key: string, + op: 'min' | 'max', +): void { + const a = target[key]; + const b = rest[key]; + if (typeof a === 'number' && typeof b === 'number') { + merged[key] = op === 'max' ? Math.max(a, b) : Math.min(a, b); + } +} + function mergeRefSiblings(target: JsonRecord, rest: JsonRecord): JsonRecord { const merged: JsonRecord = { ...target, ...rest }; + conjoinBounds(target, rest, merged, 'minLength', 'max'); + conjoinBounds(target, rest, merged, 'maxLength', 'min'); + conjoinBounds(target, rest, merged, 'minimum', 'max'); + conjoinBounds(target, rest, merged, 'maximum', 'min'); + conjoinBounds(target, rest, merged, 'exclusiveMinimum', 'max'); + conjoinBounds(target, rest, merged, 'exclusiveMaximum', 'min'); + conjoinBounds(target, rest, merged, 'minItems', 'max'); + conjoinBounds(target, rest, merged, 'maxItems', 'min'); + conjoinBounds(target, rest, merged, 'minProperties', 'max'); + conjoinBounds(target, rest, merged, 'maxProperties', 'min'); + + if (Array.isArray(target['enum']) && Array.isArray(rest['enum'])) { + const restSet = new Set(rest['enum']); + merged['enum'] = (target['enum'] as Json[]).filter((x) => restSet.has(x)); + } + if (Array.isArray(target['required']) || Array.isArray(rest['required'])) { const targetReq = Array.isArray(target['required']) ? target['required'] : []; const restReq = Array.isArray(rest['required']) ? rest['required'] : []; @@ -301,6 +331,10 @@ function recurseSchema(node: Json): void { } else { record['items'] = { anyOf: prefixItems }; } + } else if (typeof record['items'] === 'object' && record['items'] !== null) { + const existingItems = record['items']; + const combined = [...prefixItems, existingItems]; + record['items'] = { anyOf: combined }; } if (hadItemsFalse) { record['maxItems'] = diff --git a/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts index e9c41bcfd7c..a4e5db4080f 100644 --- a/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts +++ b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts @@ -511,5 +511,47 @@ describe('sanitizeMcpSchema — conditional and negation schemas', () => { const ifProps = ifSchema['properties'] as Record>; expect(ifProps['a']!['items']).toEqual({ type: 'number' }); }); + + it('conjoins numeric bounds and lengths from target and ref siblings', () => { + const schema = { + type: 'object', + properties: { + val: { + $ref: '#/$defs/Str', + minLength: 1, + maxLength: 15, + }, + }, + $defs: { + Str: { + type: 'string', + minLength: 5, + maxLength: 20, + }, + }, + }; + const result = sanitizeMcpSchema(schema); + const val = prop(result, 'val'); + expect(val['minLength']).toBe(5); + expect(val['maxLength']).toBe(15); + }); + + it('retains prefixItems when items is already an object schema', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + arr: { + type: 'array', + prefixItems: [{ type: 'string' }], + items: { type: 'number' }, + }, + }, + }); + const arr = prop(result, 'arr'); + expect(arr['prefixItems']).toBeUndefined(); + expect(arr['items']).toEqual({ + anyOf: [{ type: 'string' }, { type: 'number' }], + }); + }); }); From 9bca8dc2bce29556d8e5380412d512f869532746 Mon Sep 17 00:00:00 2001 From: creatiVision Date: Sun, 11 Oct 2026 21:32:04 +0200 Subject: [PATCH 08/10] fix(agent-core/mcp): address review on ref activity, enum equality, dependency hints, and array-union types --- .../src/mcpCore/schema-sanitize.ts | 131 ++++++++++++++++-- .../test/mcpCore/schema-sanitize.test.ts | 82 +++++++++++ 2 files changed, 205 insertions(+), 8 deletions(-) diff --git a/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts index af1a558f121..c367567160d 100644 --- a/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts +++ b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts @@ -20,6 +20,9 @@ const OBJECT_KEYWORDS = [ 'required', 'minProperties', 'maxProperties', + 'dependentRequired', + 'dependentSchemas', + 'dependencies', ] as const; const ARRAY_KEYWORDS = [ @@ -75,6 +78,32 @@ function conjoinBounds( } } +function jsonEquals(a: Json, b: Json): boolean { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!jsonEquals(a[i] as Json, b[i] as Json)) return false; + } + return true; + } + if (typeof a === 'object') { + if (Array.isArray(b) || typeof b !== 'object') return false; + const aRec = a as JsonRecord; + const bRec = b as JsonRecord; + const aKeys = Object.keys(aRec); + const bKeys = Object.keys(bRec); + if (aKeys.length !== bKeys.length) return false; + for (const key of aKeys) { + if (!Object.hasOwn(bRec, key)) return false; + if (!jsonEquals(aRec[key] as Json, bRec[key] as Json)) return false; + } + return true; + } + return false; +} + function mergeRefSiblings(target: JsonRecord, rest: JsonRecord): JsonRecord { const merged: JsonRecord = { ...target, ...rest }; @@ -90,8 +119,10 @@ function mergeRefSiblings(target: JsonRecord, rest: JsonRecord): JsonRecord { conjoinBounds(target, rest, merged, 'maxProperties', 'min'); if (Array.isArray(target['enum']) && Array.isArray(rest['enum'])) { - const restSet = new Set(rest['enum']); - merged['enum'] = (target['enum'] as Json[]).filter((x) => restSet.has(x)); + const restList = rest['enum'] as Json[]; + merged['enum'] = (target['enum'] as Json[]).filter((x) => + restList.some((y) => jsonEquals(x, y)), + ); } if (Array.isArray(target['required']) || Array.isArray(rest['required'])) { @@ -192,7 +223,7 @@ function derefJsonSchema(schema: JsonRecord): JsonRecord { const { $ref: _, ...rest } = record; const traversedRest: JsonRecord = {}; for (const [key, value] of Object.entries(rest)) { - traversedRest[key] = traverse(value, nextActive); + traversedRest[key] = traverse(value, activeRefs); } if (typeof target === 'boolean') { if (!target) return false; @@ -245,18 +276,102 @@ function inferTypeFromStructure(node: JsonRecord): string { return 'string'; } +function isBranchCompatibleWithTypeSet( + branch: Json, + allowedTypes: Set, +): boolean { + if (typeof branch === 'string') { + return allowedTypes.has(branch) || (branch === 'integer' && allowedTypes.has('number')); + } + if (typeof branch !== 'object' || branch === null || Array.isArray(branch)) { + return true; + } + const b = branch as JsonRecord; + if (typeof b['type'] === 'string') { + return ( + allowedTypes.has(b['type']) || + (b['type'] === 'integer' && allowedTypes.has('number')) + ); + } + if (Array.isArray(b['type'])) { + return (b['type'] as Json[]).some( + (t) => + typeof t === 'string' && + (allowedTypes.has(t) || (t === 'integer' && allowedTypes.has('number'))), + ); + } + if ('const' in b) { + const t = jsonTypeOf(b['const'] as Json); + return allowedTypes.has(t) || (t === 'integer' && allowedTypes.has('number')); + } + if (Array.isArray(b['enum'])) { + return (b['enum'] as Json[]).some((val) => { + const t = jsonTypeOf(val); + return allowedTypes.has(t) || (t === 'integer' && allowedTypes.has('number')); + }); + } + return true; +} + function normalizeProperty(node: Json): void { if (typeof node !== 'object' || node === null || Array.isArray(node)) return; const record = node as JsonRecord; if (Array.isArray(record['type'])) { const types = record['type'] as Json[]; - if (types.length === 1) { + if ('anyOf' in record || 'oneOf' in record) { + const allowedTypes = new Set( + types.filter((t): t is string => typeof t === 'string'), + ); + for (const key of ['anyOf', 'oneOf'] as const) { + const branches = record[key]; + if (Array.isArray(branches)) { + const filteredBranches: Json[] = []; + for (const branch of branches) { + if (isBranchCompatibleWithTypeSet(branch, allowedTypes)) { + if (typeof branch === 'object' && branch !== null && !Array.isArray(branch)) { + const b = branch as JsonRecord; + if (Array.isArray(b['type'])) { + const narrowed = (b['type'] as Json[]).filter( + (t) => + typeof t === 'string' && + (allowedTypes.has(t) || (t === 'integer' && allowedTypes.has('number'))), + ); + if (narrowed.length === 1) { + b['type'] = narrowed[0] as Json; + } else { + b['type'] = narrowed; + } + } + if (Array.isArray(b['enum'])) { + b['enum'] = (b['enum'] as Json[]).filter((val) => { + const t = jsonTypeOf(val); + return allowedTypes.has(t) || (t === 'integer' && allowedTypes.has('number')); + }); + } + if ( + !('type' in b) && + !('enum' in b) && + !('const' in b) && + !COMBINATOR_KEYS.some((k) => k in b) && + allowedTypes.size === 1 + ) { + b['type'] = [...allowedTypes][0]!; + } + } + filteredBranches.push(branch); + } + } + record[key] = filteredBranches; + } + } + delete record['type']; + } else if (types.length === 1) { record['type'] = types[0] as Json; } else if (types.length > 1) { - if (!('anyOf' in record) && !('oneOf' in record)) { - record['anyOf'] = types.map((t) => (typeof t === 'string' ? { type: t } : (t as Json))); - } + record['anyOf'] = types.map((t) => (typeof t === 'string' ? { type: t } : (t as Json))); + delete record['type']; + } else { delete record['type']; } } @@ -376,7 +491,7 @@ function recurseSchema(node: Json): void { } } - for (const key of ['patternProperties', 'dependentSchemas'] as const) { + for (const key of ['patternProperties', 'dependentSchemas', 'dependencies'] as const) { const map = record[key]; if (typeof map === 'object' && map !== null && !Array.isArray(map)) { for (const value of Object.values(map as JsonRecord)) { diff --git a/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts index a4e5db4080f..f6346be4adb 100644 --- a/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts +++ b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts @@ -553,5 +553,87 @@ describe('sanitizeMcpSchema — conditional and negation schemas', () => { anyOf: [{ type: 'string' }, { type: 'number' }], }); }); + + it('resets ref activity before traversing sibling references', () => { + const schema = { + type: 'object', + properties: { + val: { + $ref: '#/$defs/S', + allOf: [{ $ref: '#/$defs/S' }], + }, + }, + $defs: { + S: { + type: 'string', + }, + }, + }; + const result = sanitizeMcpSchema(schema); + const val = prop(result, 'val'); + expect(val['type']).toBe('string'); + expect(val['description']).toBeUndefined(); + const allOf = val['allOf'] as Record[]; + expect(allOf).toHaveLength(1); + expect(allOf[0]?.['type']).toBe('string'); + expect(allOf[0]?.['description']).toBeUndefined(); + }); + + it('intersects object-valued enums by structural equality instead of reference identity', () => { + const schema = { + type: 'object', + properties: { + item: { + $ref: '#/$defs/Base', + enum: [{ a: 1 }, { b: 2 }], + }, + }, + $defs: { + Base: { + type: 'object', + enum: [{ a: 1 }, { c: 3 }], + }, + }, + }; + const result = sanitizeMcpSchema(schema); + const item = prop(result, 'item'); + expect(item['enum']).toEqual([{ a: 1 }]); + }); + + it('infers object type from dependentSchemas or dependentRequired', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + withDepSchemas: { + dependentSchemas: { + creditCard: { properties: { billingAddress: { type: 'string' } } }, + }, + }, + withDepReq: { + dependentRequired: { + creditCard: ['billingAddress'], + }, + }, + }, + }); + expect(prop(result, 'withDepSchemas')['type']).toBe('object'); + expect(prop(result, 'withDepReq')['type']).toBe('object'); + }); + + it('preserves array-valued types beside existing anyOf by filtering incompatible branches', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + val: { + type: ['string', 'null'], + anyOf: [{ type: 'string' }, { type: 'number' }], + }, + }, + }); + const val = prop(result, 'val'); + expect(val['type']).toBeUndefined(); + expect(val['anyOf']).toEqual([{ type: 'string' }]); + }); }); + From dada019713e80d3abf701cbf263efe1e0e832933 Mon Sep 17 00:00:00 2001 From: mb <5196346+creatiVision@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:16:45 +0100 Subject: [PATCH 09/10] fix(agent-core/mcp): conjoin closed ref siblings, retain recursive constraints, and handle mixed typed enums --- .../src/mcpCore/schema-sanitize.ts | 67 ++++++++++- .../test/mcpCore/schema-sanitize.test.ts | 106 ++++++++++++++++++ 2 files changed, 171 insertions(+), 2 deletions(-) diff --git a/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts index c367567160d..421653d978b 100644 --- a/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts +++ b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts @@ -23,6 +23,7 @@ const OBJECT_KEYWORDS = [ 'dependentRequired', 'dependentSchemas', 'dependencies', + 'unevaluatedProperties', ] as const; const ARRAY_KEYWORDS = [ @@ -32,6 +33,8 @@ const ARRAY_KEYWORDS = [ 'maxItems', 'uniqueItems', 'contains', + 'unevaluatedItems', + 'additionalItems', ] as const; const STRING_KEYWORDS = ['minLength', 'maxLength', 'pattern', 'format'] as const; @@ -64,6 +67,13 @@ function jsonTypeOf(value: Json): string { return 'string'; } +function matchesType(value: Json, type: string): boolean { + const actual = jsonTypeOf(value); + if (actual === type) return true; + if (type === 'number' && actual === 'integer') return true; + return false; +} + function conjoinBounds( target: JsonRecord, rest: JsonRecord, @@ -105,6 +115,13 @@ function jsonEquals(a: Json, b: Json): boolean { } function mergeRefSiblings(target: JsonRecord, rest: JsonRecord): JsonRecord { + if ( + (target['additionalProperties'] === false && ('properties' in rest || 'patternProperties' in rest)) || + (rest['additionalProperties'] === false && ('properties' in target || 'patternProperties' in target)) + ) { + return { allOf: [target, rest] }; + } + const merged: JsonRecord = { ...target, ...rest }; conjoinBounds(target, rest, merged, 'minLength', 'max'); @@ -174,10 +191,10 @@ function derefJsonSchema(schema: JsonRecord): JsonRecord { const root = structuredClone(schema); function resolvePointer(pointer: string): Json { - const pathStr = pointer.replace(/^#\/?/, ''); - if (pathStr === '') { + if (pointer === '#') { return root; } + const pathStr = pointer.startsWith('#/') ? pointer.slice(2) : pointer.replace(/^#/, ''); const parts = pathStr.split('/').map(decodePointerSegment); let current: Json = root; for (const part of parts) { @@ -215,6 +232,42 @@ function derefJsonSchema(schema: JsonRecord): JsonRecord { const ref = record['$ref']; if (ref.startsWith('#')) { if (activeRefs.has(ref)) { + const targetRaw = resolvePointer(ref); + if (typeof targetRaw === 'boolean') return targetRaw; + if (typeof targetRaw === 'object' && targetRaw !== null && !Array.isArray(targetRaw)) { + const cutoff: JsonRecord = { description: 'Circular reference' }; + for (const [k, v] of Object.entries(targetRaw as JsonRecord)) { + if (k !== '$ref' && k !== 'properties' && k !== 'items' && k !== 'prefixItems') { + cutoff[k] = structuredClone(v); + } + } + if (!('type' in cutoff)) { + cutoff['type'] = inferTypeFromStructure(targetRaw as JsonRecord); + } + if ( + typeof (targetRaw as JsonRecord)['properties'] === 'object' && + (targetRaw as JsonRecord)['properties'] !== null && + !Array.isArray((targetRaw as JsonRecord)['properties']) + ) { + const targetProps = (targetRaw as JsonRecord)['properties'] as JsonRecord; + const cutoffProps: JsonRecord = {}; + for (const [pKey, pVal] of Object.entries(targetProps)) { + if ( + typeof pVal === 'object' && + pVal !== null && + !Array.isArray(pVal) && + (pVal as JsonRecord)['$ref'] === ref + ) { + continue; + } + cutoffProps[pKey] = structuredClone(pVal); + } + if (Object.keys(cutoffProps).length > 0) { + cutoff['properties'] = cutoffProps; + } + } + return cutoff; + } return { type: 'object', description: 'Circular reference' }; } const nextActive = new Set(activeRefs); @@ -401,6 +454,16 @@ function normalizeProperty(node: Json): void { } } + if (typeof record['type'] === 'string' && Array.isArray(record['enum'])) { + const expectedType = record['type']; + const filtered = (record['enum'] as Json[]).filter((v) => matchesType(v, expectedType)); + if (filtered.length > 0) { + record['enum'] = filtered; + } else { + delete record['enum']; + } + } + if (!('type' in record) && !COMBINATOR_KEYS.some((key) => key in record)) { const enumValues = record['enum']; if (Array.isArray(enumValues) && enumValues.length > 0) { diff --git a/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts index f6346be4adb..c85b2dae53b 100644 --- a/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts +++ b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts @@ -634,6 +634,112 @@ describe('sanitizeMcpSchema — conditional and negation schemas', () => { expect(val['type']).toBeUndefined(); expect(val['anyOf']).toEqual([{ type: 'string' }]); }); + + it('keeps #/ distinct from # by resolving empty-string property on root', () => { + const schema = { + type: 'object', + '': { + type: 'string', + description: 'empty-key property', + }, + properties: { + val: { $ref: '#/' }, + }, + }; + const result = sanitizeMcpSchema(schema); + const val = prop(result, 'val'); + expect(val['type']).toBe('string'); + expect(val['description']).toBe('empty-key property'); + }); + + it('preserves target structural constraints on recursive refs', () => { + const schema = { + type: 'object', + properties: { + node: { $ref: '#/$defs/Node' }, + }, + $defs: { + Node: { + type: 'object', + required: ['value'], + properties: { + value: { type: 'string' }, + next: { $ref: '#/$defs/Node' }, + }, + }, + }, + }; + const result = sanitizeMcpSchema(schema); + const node = prop(result, 'node'); + expect(node['type']).toBe('object'); + expect(node['required']).toEqual(['value']); + const next = prop(node, 'next'); + expect(next['type']).toBe('object'); + expect(next['required']).toEqual(['value']); + expect(next['description']).toBe('Circular reference'); + }); + + it('filters mixed enum values that contradict explicit string type', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + mode: { + type: 'string', + enum: ['auto', 1, true], + }, + }, + }); + const mode = prop(result, 'mode'); + expect(mode['type']).toBe('string'); + expect(mode['enum']).toEqual(['auto']); + }); + + it('infers container types from unevaluatedProperties and unevaluatedItems', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + obj: { + unevaluatedProperties: false, + }, + arr: { + unevaluatedItems: false, + }, + arr2: { + additionalItems: false, + }, + }, + }); + expect(prop(result, 'obj')['type']).toBe('object'); + expect(prop(result, 'arr')['type']).toBe('array'); + expect(prop(result, 'arr2')['type']).toBe('array'); + }); + + it('preserves closed-object semantics when merging ref siblings with additionalProperties false', () => { + const schema = { + type: 'object', + properties: { + closed: { + $ref: '#/$defs/Target', + properties: { b: { type: 'string' } }, + required: ['b'], + }, + }, + $defs: { + Target: { + type: 'object', + properties: { a: { type: 'number' } }, + additionalProperties: false, + }, + }, + }; + const result = sanitizeMcpSchema(schema); + const closed = prop(result, 'closed'); + expect(closed['allOf']).toBeDefined(); + expect(Array.isArray(closed['allOf'])).toBe(true); + const allOf = closed['allOf'] as Record[]; + expect(allOf).toHaveLength(2); + expect(allOf[0]?.['additionalProperties']).toBe(false); + }); }); From 5a4d9f3ac8536f19df4731bbd03163e5c56f55cd Mon Sep 17 00:00:00 2001 From: mb <5196346+creatiVision@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:08:27 +0100 Subject: [PATCH 10/10] fix(agent-core/mcp): preserve empty contradictory enums, replace nested cutoff refs, and map tuple additionalItems false --- .../src/mcpCore/schema-sanitize.ts | 31 ++++++++-- .../test/mcpCore/schema-sanitize.test.ts | 60 +++++++++++++++++++ 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts index 421653d978b..43aedd380e5 100644 --- a/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts +++ b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts @@ -67,6 +67,22 @@ function jsonTypeOf(value: Json): string { return 'string'; } +function stripOrReplaceRef(val: unknown, ref: string): unknown { + if (typeof val !== 'object' || val === null) return val; + if (Array.isArray(val)) { + return val.map((item) => stripOrReplaceRef(item, ref)); + } + const rec = val as JsonRecord; + if (rec['$ref'] === ref) { + return { type: 'object', description: 'Circular reference' }; + } + const result: JsonRecord = {}; + for (const [k, v] of Object.entries(rec)) { + result[k] = stripOrReplaceRef(v, ref) as Json; + } + return result; +} + function matchesType(value: Json, type: string): boolean { const actual = jsonTypeOf(value); if (actual === type) return true; @@ -260,7 +276,7 @@ function derefJsonSchema(schema: JsonRecord): JsonRecord { ) { continue; } - cutoffProps[pKey] = structuredClone(pVal); + cutoffProps[pKey] = stripOrReplaceRef(structuredClone(pVal), ref) as Json; } if (Object.keys(cutoffProps).length > 0) { cutoff['properties'] = cutoffProps; @@ -457,11 +473,7 @@ function normalizeProperty(node: Json): void { if (typeof record['type'] === 'string' && Array.isArray(record['enum'])) { const expectedType = record['type']; const filtered = (record['enum'] as Json[]).filter((v) => matchesType(v, expectedType)); - if (filtered.length > 0) { - record['enum'] = filtered; - } else { - delete record['enum']; - } + record['enum'] = filtered; } if (!('type' in record) && !COMBINATOR_KEYS.some((key) => key in record)) { @@ -526,6 +538,13 @@ function recurseSchema(node: Json): void { const items = record['items']; if (Array.isArray(items)) { for (const value of items) normalizeProperty(value); + if (record['additionalItems'] === false) { + record['maxItems'] = + typeof record['maxItems'] === 'number' + ? Math.min(record['maxItems'], items.length) + : items.length; + } + delete record['additionalItems']; if (items.length === 0) { delete record['items']; } else if (items.length === 1) { diff --git a/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts index c85b2dae53b..f639ca6357a 100644 --- a/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts +++ b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts @@ -740,6 +740,66 @@ describe('sanitizeMcpSchema — conditional and negation schemas', () => { expect(allOf).toHaveLength(2); expect(allOf[0]?.['additionalProperties']).toBe(false); }); + + it('keeps contradictory typed enum unsatisfiable with empty enum array', () => { + const schema = { + type: 'object', + properties: { + contradiction: { + type: 'string', + enum: [1, 2], + }, + }, + }; + const result = sanitizeMcpSchema(schema); + const c = prop(result, 'contradiction'); + expect(c['type']).toBe('string'); + expect(c['enum']).toEqual([]); + }); + + it('replaces nested circular refs in recursive cutoffs to avoid dangling refs', () => { + const schema = { + type: 'object', + properties: { + root: { $ref: '#/$defs/Node' }, + }, + $defs: { + Node: { + type: 'object', + properties: { + child: { + anyOf: [{ $ref: '#/$defs/Node' }, { type: 'null' }], + }, + }, + }, + }, + }; + const result = sanitizeMcpSchema(schema); + const root = prop(result, 'root'); + expect(result['$defs']).toBeUndefined(); + const child = prop(root, 'child'); + expect(child['anyOf']).toBeDefined(); + const anyOf = child['anyOf'] as Record[]; + expect(anyOf[0]?.['$ref']).toBeUndefined(); + expect(anyOf[0]?.['type']).toBe('object'); + }); + + it('preserves maxItems when positional tuple specifies additionalItems false', () => { + const schema = { + type: 'object', + properties: { + tuple: { + type: 'array', + items: [{ type: 'string' }, { type: 'number' }], + additionalItems: false, + }, + }, + }; + const result = sanitizeMcpSchema(schema); + const tuple = prop(result, 'tuple'); + expect(tuple['maxItems']).toBe(2); + expect(tuple['additionalItems']).toBeUndefined(); + }); });