diff --git a/.changeset/mfjs-schema-sanitize.md b/.changeset/mfjs-schema-sanitize.md new file mode 100644 index 0000000000..cd29fdb42b --- /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 83afa8c70c..7ddb5a38b7 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 8774de86f6..4d43f68186 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 0000000000..b25324e70b --- /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 0000000000..cf9b2df59b --- /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'); + }); +});