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/human/eventStore/journal.ts b/packages/agent-core-v2/src/human/eventStore/journal.ts index 5b60e9ff7d2..aeb8a5b536f 100644 --- a/packages/agent-core-v2/src/human/eventStore/journal.ts +++ b/packages/agent-core-v2/src/human/eventStore/journal.ts @@ -94,7 +94,7 @@ async function* readBranchChain(branch: Branch, tree: Tree): AsyncIterable= 0; i--) { let isAscii = false; - const code = text.charCodeAt(i); + const code = text.codePointAt(i); if (code >= 0xdc00 && code <= 0xdfff && i > 0) { - const high = text.charCodeAt(i - 1); + const high = text.codePointAt(i - 1); if (high >= 0xd800 && high <= 0xdbff) { i--; } diff --git a/packages/agent-core-v2/src/mcpCore/config-schema.ts b/packages/agent-core-v2/src/mcpCore/config-schema.ts index fe9b6a0ab6d..8de581b83b6 100644 --- a/packages/agent-core-v2/src/mcpCore/config-schema.ts +++ b/packages/agent-core-v2/src/mcpCore/config-schema.ts @@ -58,7 +58,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 57476611cd3..b7bd4a88af2 100644 --- a/packages/agent-core-v2/src/mcpCore/connection-manager.ts +++ b/packages/agent-core-v2/src/mcpCore/connection-manager.ts @@ -20,6 +20,7 @@ import { } 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'; @@ -869,7 +870,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)), })), }; } catch (error) { 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..43aedd380e5 --- /dev/null +++ b/packages/agent-core-v2/src/mcpCore/schema-sanitize.ts @@ -0,0 +1,612 @@ +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', + 'dependentRequired', + 'dependentSchemas', + 'dependencies', + 'unevaluatedProperties', +] as const; + +const ARRAY_KEYWORDS = [ + 'items', + 'prefixItems', + 'minItems', + 'maxItems', + 'uniqueItems', + 'contains', + 'unevaluatedItems', + 'additionalItems', +] 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 { + let decoded = segment; + try { + decoded = decodeURIComponent(segment); + } catch { + decoded = segment; + } + return decoded.replaceAll('~1', '/').replaceAll('~0', '~'); +} + +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 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; + if (type === 'number' && actual === 'integer') return true; + return false; +} + +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 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 { + 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'); + 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 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'])) { + 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); + + function resolvePointer(pointer: string): Json { + 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) { + 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)) { + 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] = stripOrReplaceRef(structuredClone(pVal), ref) as Json; + } + if (Object.keys(cutoffProps).length > 0) { + cutoff['properties'] = cutoffProps; + } + } + return cutoff; + } + return { type: 'object', description: 'Circular reference' }; + } + const nextActive = new Set(activeRefs); + nextActive.add(ref); + const target = traverse(resolvePointer(ref), nextActive); + const { $ref: _, ...rest } = record; + const traversedRest: JsonRecord = {}; + for (const [key, value] of Object.entries(rest)) { + traversedRest[key] = traverse(value, activeRefs); + } + 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; + } + 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 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 ('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) { + record['anyOf'] = types.map((t) => (typeof t === 'string' ? { type: t } : (t as Json))); + delete record['type']; + } else { + 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 (typeof record['type'] === 'string' && Array.isArray(record['enum'])) { + const expectedType = record['type']; + const filtered = (record['enum'] as Json[]).filter((v) => matchesType(v, expectedType)); + record['enum'] = filtered; + } + + 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 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']; + } else if (prefixItems.length === 1) { + record['items'] = prefixItems[0] as Json; + } 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'] = + typeof record['maxItems'] === 'number' + ? Math.min(record['maxItems'], prefixItems.length) + : prefixItems.length; + } + delete record['prefixItems']; + } + + 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) { + record['items'] = items[0] as Json; + } else { + record['items'] = { anyOf: items }; + } + } else if (typeof items === 'object' && items !== null) { + normalizeProperty(items); + } else if (items === false) { + record['maxItems'] = 0; + delete record['items']; + } else if (items === true || (items !== undefined && typeof items !== 'object')) { + delete record['items']; + } + + const additional = record['additionalProperties']; + if (typeof additional === 'object' && additional !== null && !Array.isArray(additional)) { + 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', '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)) { + normalizeProperty(value); + } + } + } + + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { + const branches = record[key]; + if (Array.isArray(branches)) { + 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 }); + } else if (typeof value === 'boolean') { + normalizedBranches.push(value); + } + } + record[key] = normalizedBranches; + } + } +} + +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); + 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 new file mode 100644 index 00000000000..f639ca6357a --- /dev/null +++ b/packages/agent-core-v2/test/mcpCore/schema-sanitize.test.ts @@ -0,0 +1,805 @@ +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' }); + }); + + 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', () => { + 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'); + }); + + 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' }]); + }); +}); + +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('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: { + pair: { + type: 'array', + prefixItems: [{ type: 'string' }, { type: 'number' }], + items: false, + maxItems: 10, + }, + }, + }); + const pair = prop(result, 'pair'); + expect(pair['maxItems']).toBe(2); + }); +}); + +describe('sanitizeMcpSchema — boolean combinator branches', () => { + it('retains boolean branches in combinator arrays directly', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + allowed: { + anyOf: [{ type: 'string' }, true], + }, + denied: { + allOf: [{ type: 'string' }, false], + }, + }, + }); + 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' }); + }); + + 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' }], + }); + }); + + 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' }]); + }); + + 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); + }); + + 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(); + }); +}); + +