From 519a531eb5aedbad187ea0c55bac34994b43c22e Mon Sep 17 00:00:00 2001 From: mb Date: Tue, 16 Jun 2026 23:50:19 +0200 Subject: [PATCH 1/5] fix(agent-core): prevent stack overflow on circular schema refs in sanitizeMcpSchema --- .../agent-core/src/mcp/schema-sanitize.ts | 271 ++++++++++++ .../test/mcp/schema-sanitize.test.ts | 384 ++++++++++++++++++ 2 files changed, 655 insertions(+) create mode 100644 packages/agent-core/src/mcp/schema-sanitize.ts create mode 100644 packages/agent-core/test/mcp/schema-sanitize.test.ts diff --git a/packages/agent-core/src/mcp/schema-sanitize.ts b/packages/agent-core/src/mcp/schema-sanitize.ts new file mode 100644 index 000000000..8ae59f908 --- /dev/null +++ b/packages/agent-core/src/mcp/schema-sanitize.ts @@ -0,0 +1,271 @@ +/** + * Sanitize standard JSON Schemas emitted by MCP servers into the stricter + * "Moonshot Flavored JSON Schema" (MFJS) the Kimi API validator expects. + * + * ## Background + * + * MCP servers advertise tool input schemas as standard JSON Schema objects. + * Standard JSON Schema permits property schemas that omit the `type` keyword + * (e.g. `{"enum": ["a", "b"]}`) and freely uses combinators (`anyOf`, + * `oneOf`, `allOf`) and `$ref` indirection. Most LLM providers (OpenAI, + * Anthropic) accept these without issue. + * + * Moonshot's validator is stricter: every property must carry an explicit + * `type`, and `$ref` pointers must be resolved inline. Without sanitization + * the API returns HTTP 400: + * + * > tools.function.parameters is not a valid moonshot flavored json schema, + * > details: + * + * This module is a TypeScript port of the original kosong interceptor that + * shipped in the Python-based kimi-cli (`kosong/utils/jsonschema.py`). + * + * ## What it does + * + * 1. **Dereferences local `$ref`** entries (`#/$defs/...`) so the resolved + * schema contains no indirection, then strips the definition buckets. + * 2. **Fills in missing `type`** on every property schema — inferred from + * `enum`/`const` values, from structural keywords (`properties` → + * `"object"`, `items` → `"array"`, etc.), or defaulting to `"string"`. + * + * Combinator branches (`anyOf`/`oneOf`/`allOf`/`$ref`/`not`/`if`/`then`/ + * `else`) are left alone because they legitimately describe shape without + * `type`. + */ + +type Json = string | number | boolean | null | Json[] | { [key: string]: Json }; +type JsonRecord = Record; + +/** + * JSON Schema keywords that describe a property's shape without (or in + * addition to) a `type` keyword. When any of these are present we skip the + * type-filling step so we don't distort the schema's meaning. + */ +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; + +/** + * Resolve local `$ref` entries inside a JSON Schema, then return a deep copy + * with every reference inlined and the definition buckets removed. + * + * Only local references (those starting with `#`) are resolved; remote + * references (e.g. `https://...`) are left untouched. + * + * @throws if a local `$ref` cannot be resolved or resolves to a non-object. + */ +function derefJsonSchema(schema: JsonRecord): JsonRecord { + const root = structuredClone(schema); + + function resolvePointer(pointer: string): Json { + const parts = pointer.replace(/^#\/?/, '').split('/'); + let current: Json = root; + for (const part of parts) { + if (typeof current !== 'object' || current === null || Array.isArray(current)) { + throw new Error(`Unable to resolve reference path: ${pointer}`); + } + current = (current as JsonRecord)[part] ?? null; + if (current === undefined) { + throw new Error(`Unable to resolve reference path: ${pointer}`); + } + } + 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; + return { ...(target as JsonRecord), ...rest }; + } + // Remote reference — leave as-is. + 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; +} + +/** + * Walk into every property-schema position under `node` and ensure each + * declares a `type`. Mutates the node in place (the caller should pass a + * deep clone). + * + * Property-schema positions are: values under `properties`, entries in + * `items` (object or array form), `additionalProperties` (object form), and + * branches of `anyOf`/`oneOf`/`allOf`. + * + * `node` itself is treated as a container and is not normalized — only the + * property schemas it contains are. + */ +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 (typeof items === 'object' && items !== null) { + if (Array.isArray(items)) { + for (const value of items) normalizeProperty(value); + } else { + normalizeProperty(items); + } + } + + 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); + } + } +} + +/** + * Ensure `node` (a property schema) declares a `type`, then recurse into it. + */ +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) { + record['type'] = inferTypeFromValues(enumValues); + } else if ('const' in record) { + record['type'] = inferTypeFromValues([record['const']]); + } else { + record['type'] = inferTypeFromStructure(record); + } + } + + recurseSchema(record); +} + +/** + * Infer a JSON Schema `type` from structural keywords present on `node`. + * + * Falls back to `"string"` only when the node carries no structural hints. + */ +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'; +} + +/** + * Infer a JSON Schema `type` string from a list of concrete values. + * + * - Single type → return it. + * - `{integer, number}` → `"number"` (integer is a subset of number). + * - Mixed → `"string"`. + */ +function inferTypeFromValues(values: Json[]): string { + const inferred = new Set(); + for (const value of values) { + if (typeof value === 'boolean') inferred.add('boolean'); + else if (typeof value === 'number') { + inferred.add(Number.isInteger(value) ? 'integer' : 'number'); + } else if (typeof value === 'string') inferred.add('string'); + else if (value === null) inferred.add('null'); + else if (Array.isArray(value)) inferred.add('array'); + else if (typeof value === 'object') inferred.add('object'); + else return 'string'; + } + if (inferred.size === 1) return [...inferred][0]!; + if (inferred.size === 2 && inferred.has('integer') && inferred.has('number')) return 'number'; + return 'string'; +} + +/** + * Sanitize a standard JSON Schema (as emitted by MCP servers) into + * Moonshot Flavored JSON Schema: resolve local `$ref` pointers and fill in + * missing `type` declarations on every property. + * + * Returns a **new** object; the input is never mutated. Non-object inputs + * are returned unchanged so callers can use this as an identity pass-through + * for edge cases (MCP servers occasionally emit `true` or `false` as a + * schema). + */ +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/test/mcp/schema-sanitize.test.ts b/packages/agent-core/test/mcp/schema-sanitize.test.ts new file mode 100644 index 000000000..025730791 --- /dev/null +++ b/packages/agent-core/test/mcp/schema-sanitize.test.ts @@ -0,0 +1,384 @@ +import { describe, expect, it } from 'vitest'; + +import { sanitizeMcpSchema } from '../../src/mcp/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 (invalid schema, but identity)', () => { + 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] }, + mixed: { enum: [1, 'a'] }, + }, + }); + expect(prop(result, 'mode')['type']).toBe('string'); + expect(prop(result, 'count')['type']).toBe('integer'); + expect(prop(result, 'enabled')['type']).toBe('boolean'); + expect(prop(result, 'mixed')['type']).toBe('string'); + }); + + 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(); + }); + + it('does not overwrite an existing type', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + name: { type: 'string', enum: ['a', 'b'] }, + }, + }); + expect(prop(result, 'name')['type']).toBe('string'); + }); +}); + +describe('sanitizeMcpSchema — $ref dereferencing', () => { + it('inlines local $ref pointers and strips $defs', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + user: { $ref: '#/$defs/User' }, + }, + $defs: { + User: { + type: 'object', + properties: { + name: {}, + age: {}, + }, + }, + }, + }); + + expect('$defs' in result).toBe(false); + const userProp = prop(result, 'user'); + expect(userProp['type']).toBe('object'); + const userProps = props(userProp); + expect(userProps['name']!['type']).toBe('string'); + expect(userProps['age']!['type']).toBe('string'); + }); + + it('preserves sibling keys alongside $ref', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + item: { $ref: '#/$defs/Item', description: 'overridden' }, + }, + $defs: { + Item: { type: 'string' }, + }, + }); + const itemProp = prop(result, 'item'); + expect(itemProp['type']).toBe('string'); + expect(itemProp['description']).toBe('overridden'); + }); + + it('handles nested $ref chains', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + a: { $ref: '#/$defs/A' }, + }, + $defs: { + A: { $ref: '#/$defs/B' }, + B: { type: 'integer' }, + }, + }); + expect(prop(result, 'a')['type']).toBe('integer'); + }); + + it('leaves remote $ref untouched', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + remote: { $ref: 'https://example.com/schema.json' }, + }, + }); + expect(prop(result, 'remote')['$ref']).toBe('https://example.com/schema.json'); + }); + + it('strips legacy "definitions" bucket', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: {}, + definitions: { Foo: { type: 'string' } }, + }); + expect('definitions' in result).toBe(false); + }); + + it('throws on unresolvable local $ref', () => { + expect(() => + sanitizeMcpSchema({ + type: 'object', + properties: { + broken: { $ref: '#/$defs/Missing' }, + }, + }), + ).toThrow(/Unable to resolve reference path/); + }); +}); + +describe('sanitizeMcpSchema — nested arrays and items', () => { + it('fills type on array items (object form)', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + tags: { type: 'array', items: { description: 'a tag' } }, + }, + }); + const tagsProp = prop(result, 'tags'); + expect((tagsProp['items'] as Schema)['type']).toBe('string'); + }); + + it('fills type on array items (array form / tuple)', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + pair: { type: 'array', items: [{ description: 'first' }, { type: 'number' }] }, + }, + }); + const pairProp = prop(result, 'pair'); + const items = pairProp['items'] as Schema[]; + expect(items[0]!['type']).toBe('string'); + expect(items[1]!['type']).toBe('number'); + }); + + it('fills type on additionalProperties (object form)', () => { + const result = sanitizeMcpSchema({ + type: 'object', + additionalProperties: { description: 'dynamic value' }, + }); + expect((result['additionalProperties'] as Schema)['type']).toBe('string'); + }); +}); + +describe('sanitizeMcpSchema — regression: issue #792 (n8n anyOf)', () => { + // Reproduces the exact schema shape from issue #792 where n8n emits a + // property with `anyOf` whose branches lack explicit `type`. Moonshot's + // validator rejects this because `anyOf` branches must be objects. + it('fills type on anyOf/oneOf/allOf branches', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + pageSetup: { + type: 'object', + properties: { + size: { + anyOf: [ + { description: 'small' }, + { description: 'large', format: 'paper-size' }, + ], + }, + }, + }, + }, + }); + + const pageSetup = prop(result, 'pageSetup'); + const size = prop(pageSetup, 'size'); + const branches = size['anyOf'] as Schema[]; + expect(branches[0]!['type']).toBe('string'); + expect(branches[1]!['type']).toBe('string'); + }); + + it('recurses into anyOf branches that contain nested properties', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + config: { + anyOf: [ + { + properties: { + host: { description: 'hostname' }, + port: { minimum: 0 }, + }, + }, + ], + }, + }, + }); + + const config = prop(result, 'config'); + // The config property uses a combinator (anyOf), so no type is added at + // the config level. Inside the branch, nested properties get filled. + expect(config['type']).toBeUndefined(); + const branch = (config['anyOf'] as Schema[])[0]!; + const branchProps = props(branch); + expect(branchProps['host']!['type']).toBe('string'); + expect(branchProps['port']!['type']).toBe('number'); + }); +}); + +describe('sanitizeMcpSchema — immutability', () => { + it('never mutates the input schema', () => { + const input = { + type: 'object', + properties: { + name: { description: 'test' }, + }, + }; + const inputCopy = structuredClone(input); + sanitizeMcpSchema(input); + expect(input).toEqual(inputCopy); + }); +}); + +describe('sanitizeMcpSchema — integer/number unification', () => { + it('classifies {integer, number} mixed enum as "number"', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + value: { enum: [1, 2.5] }, + }, + }); + expect(prop(result, 'value')['type']).toBe('number'); + }); + + it('classifies pure integer enum as "integer"', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + value: { enum: [1, 2, 3] }, + }, + }); + expect(prop(result, 'value')['type']).toBe('integer'); + }); +}); + +describe('sanitizeMcpSchema — recursive / circular schemas', () => { + it('handles circular references without throwing or stack overflowing', () => { + const schema = { + type: 'object', + properties: { + children: { + type: 'array', + items: { + anyOf: [ + { type: 'string' }, + { $ref: '#/definitions/__schema0' }, + ], + }, + }, + }, + definitions: { + __schema0: { + type: 'object', + properties: { + name: { type: 'string' }, + children: { + type: 'array', + items: { + anyOf: [ + { type: 'string' }, + { $ref: '#/definitions/__schema0' }, + ], + }, + }, + }, + }, + }, + }; + + const result = sanitizeMcpSchema(schema); + + // Check that definitions got stripped + expect('definitions' in result).toBe(false); + + // Check that children exists + const children = prop(result, 'children'); + expect(children['type']).toBe('array'); + + // Verify circular reference was safely resolved to type object at the second level + const items = children['items'] as Schema; + const branches = items['anyOf'] as Schema[]; + expect(branches[0]!['type']).toBe('string'); + expect(branches[1]!['type']).toBe('object'); + + // Drill down to the circular reference + const nestedChildren = prop(branches[1], 'children'); + expect(nestedChildren['type']).toBe('array'); + const nestedItems = nestedChildren['items'] as Schema; + const nestedBranches = nestedItems['anyOf'] as Schema[]; + expect(nestedBranches[0]!['type']).toBe('string'); + expect(nestedBranches[1]!['type']).toBe('object'); + expect(nestedBranches[1]!['description']).toBe('Circular reference'); + }); +}); From 7ba1449c59ec3eb05fe9bd11b44aabc30a25caa8 Mon Sep 17 00:00:00 2001 From: mb Date: Tue, 16 Jun 2026 23:50:22 +0200 Subject: [PATCH 2/5] feat(agent-core): add compatibility mapping for standard disabled and max_tokens config fields --- packages/agent-core/src/config/schema.ts | 5 ++++- packages/agent-core/src/config/toml.ts | 10 ++++++++- .../agent-core/test/config/configs.test.ts | 22 +++++++++++++++++++ .../agent-core/test/mcp/config-loader.test.ts | 22 +++++++++++++++++++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 9b3d11cf0..44ee48971 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -191,7 +191,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; + let obj = { ...raw } as Record; + if ('disabled' 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/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index 172e97cfc..e603e5f93 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -359,7 +359,15 @@ function transformProviderData(data: Record): Record): Record { - return transformPlainObject(data); + const out = transformPlainObject(data); + if (!('maxOutputSize' in out)) { + if ('maxOutputTokens' in out && typeof out['maxOutputTokens'] === 'number') { + out['maxOutputSize'] = out['maxOutputTokens']; + } else if ('maxTokens' in out && typeof out['maxTokens'] === 'number') { + out['maxOutputSize'] = out['maxTokens']; + } + } + return out; } function transformPermissionData(data: Record): Record { diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index 091eee384..5fc123f60 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -453,6 +453,28 @@ hooks = [{ type = "pre-tool-call", command = "echo hi" }] ErrorCodes.CONFIG_INVALID, ); }); + + it('maps deprecated max_tokens and max_output_tokens to maxOutputSize', () => { + const tomlWithMaxTokens = ` +[models.test] +provider = "managed:kimi-code" +model = "test-model" +max_context_size = 128000 +max_tokens = 4096 +`; + const configWithMaxTokens = parseConfigString(tomlWithMaxTokens, 'config.toml'); + expect(configWithMaxTokens.models?.['test']?.maxOutputSize).toBe(4096); + + const tomlWithMaxOutputTokens = ` +[models.test] +provider = "managed:kimi-code" +model = "test-model" +max_context_size = 128000 +max_output_tokens = 8192 +`; + const configWithMaxOutputTokens = parseConfigString(tomlWithMaxOutputTokens, 'config.toml'); + expect(configWithMaxOutputTokens.models?.['test']?.maxOutputSize).toBe(8192); + }); }); describe('harness config schema and patch merge', () => { diff --git a/packages/agent-core/test/mcp/config-loader.test.ts b/packages/agent-core/test/mcp/config-loader.test.ts index f281c0551..2ad54f363 100644 --- a/packages/agent-core/test/mcp/config-loader.test.ts +++ b/packages/agent-core/test/mcp/config-loader.test.ts @@ -228,6 +228,28 @@ describe('loadMcpServers', () => { }); }); + it('translates standard disabled=true into enabled=false', async () => { + const home = makeTempDir(); + const cwd = makeTempDir(); + await writeJson(join(home, 'mcp.json'), { + mcpServers: { + disabledServer: { command: 'node', disabled: true }, + enabledServer: { command: 'node', disabled: false }, + }, + }); + const servers = await loadMcpServers({ cwd, homeDir: home }); + expect(servers['disabledServer']).toEqual({ + transport: 'stdio', + command: 'node', + enabled: false, + }); + expect(servers['enabledServer']).toEqual({ + transport: 'stdio', + command: 'node', + enabled: true, + }); + }); + it('infers transport=http when an entry omits transport but has url', async () => { const home = makeTempDir(); const cwd = makeTempDir(); From 6f20830c5ce1edf9a1959f8db763367e25d0d07d Mon Sep 17 00:00:00 2001 From: mb Date: Tue, 16 Jun 2026 23:50:32 +0200 Subject: [PATCH 3/5] fix(agent-core): integrate sanitizeMcpSchema into connectAndDiscoverTools --- packages/agent-core/src/mcp/connection-manager.ts | 14 +++++++++----- packages/agent-core/src/mcp/index.ts | 1 + 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/agent-core/src/mcp/connection-manager.ts b/packages/agent-core/src/mcp/connection-manager.ts index 7d3c9c1f3..291a4932c 100644 --- a/packages/agent-core/src/mcp/connection-manager.ts +++ b/packages/agent-core/src/mcp/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 './oauth'; +import { sanitizeMcpSchema } from './schema-sanitize'; import { assertMcpInputSchema, type MCPClient } from './types'; export type McpServerStatus = 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth'; @@ -378,11 +379,14 @@ export class McpConnectionManager { private async connectAndDiscoverTools(client: RuntimeMcpClient): Promise { await client.connect(); const mcpTools = await client.listTools(); - return mcpTools.map((mcpTool) => ({ - name: mcpTool.name, - description: mcpTool.description, - parameters: assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema), - })); + return mcpTools.map((mcpTool) => { + const validated = assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema); + return { + name: mcpTool.name, + description: mcpTool.description, + parameters: sanitizeMcpSchema(validated), + }; + }); } private async closeClient(entry: InternalEntry): Promise { diff --git a/packages/agent-core/src/mcp/index.ts b/packages/agent-core/src/mcp/index.ts index d0fef23f6..e6d835c8b 100644 --- a/packages/agent-core/src/mcp/index.ts +++ b/packages/agent-core/src/mcp/index.ts @@ -1,5 +1,6 @@ export * from './connection-manager'; export * from './oauth'; +export * from './schema-sanitize'; export * from './session-config'; export * from './tool-naming'; export * from './types'; From 6dc734faf2352453cc8fec84a471dd195d04370e Mon Sep 17 00:00:00 2001 From: mb Date: Wed, 17 Jun 2026 00:05:22 +0200 Subject: [PATCH 4/5] docs: add changeset for circular mcp schema and config compatibility fixes --- .changeset/mfjs-schema-sanitize.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/mfjs-schema-sanitize.md diff --git a/.changeset/mfjs-schema-sanitize.md b/.changeset/mfjs-schema-sanitize.md new file mode 100644 index 000000000..5337481bf --- /dev/null +++ b/.changeset/mfjs-schema-sanitize.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Prevent Maximum call stack size exceeded crash on circular/recursive MCP schemas and add compatibility mappings for standard "disabled", "max_tokens", and "max_output_tokens" settings. From 8384b5ccdc04edabea93f7234ecb89a866e07009 Mon Sep 17 00:00:00 2001 From: mb Date: Wed, 17 Jun 2026 00:16:53 +0200 Subject: [PATCH 5/5] fix(agent-core): resolve root self-recursive references (#) safely in resolvePointer --- .../agent-core/src/mcp/schema-sanitize.ts | 6 +++++- .../test/mcp/schema-sanitize.test.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/agent-core/src/mcp/schema-sanitize.ts b/packages/agent-core/src/mcp/schema-sanitize.ts index 8ae59f908..e608fbcf6 100644 --- a/packages/agent-core/src/mcp/schema-sanitize.ts +++ b/packages/agent-core/src/mcp/schema-sanitize.ts @@ -94,7 +94,11 @@ function derefJsonSchema(schema: JsonRecord): JsonRecord { const root = structuredClone(schema); function resolvePointer(pointer: string): Json { - const parts = pointer.replace(/^#\/?/, '').split('/'); + const pathStr = pointer.replace(/^#\/?/, ''); + if (pathStr === '') { + return root; + } + const parts = pathStr.split('/'); let current: Json = root; for (const part of parts) { if (typeof current !== 'object' || current === null || Array.isArray(current)) { diff --git a/packages/agent-core/test/mcp/schema-sanitize.test.ts b/packages/agent-core/test/mcp/schema-sanitize.test.ts index 025730791..9c27996a6 100644 --- a/packages/agent-core/test/mcp/schema-sanitize.test.ts +++ b/packages/agent-core/test/mcp/schema-sanitize.test.ts @@ -381,4 +381,23 @@ describe('sanitizeMcpSchema — recursive / circular schemas', () => { expect(nestedBranches[1]!['type']).toBe('object'); expect(nestedBranches[1]!['description']).toBe('Circular reference'); }); + + it('handles root self-recursive references ($ref: "#") safely', () => { + const schema = { + type: 'object', + properties: { + self: { $ref: '#' }, + }, + }; + + const result = sanitizeMcpSchema(schema); + + // Verify circular reference at the root level was safely resolved at the nested level + 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'); + }); });