Skip to content

Commit 3f58cdc

Browse files
author
mb
committed
chore: merge fix/mcp-schema-limits into fork main
2 parents 094f10d + 51ab3ef commit 3f58cdc

10 files changed

Lines changed: 754 additions & 6 deletions

File tree

.changeset/mfjs-schema-sanitize.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@moonshot-ai/agent-core": patch
3+
"@moonshot-ai/kimi-code": patch
4+
---
5+
6+
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.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,5 @@ handover.md
4545
*-mockup.html
4646
*-demo.html
4747
*-demos.html
48+
plugins/**
49+
plugins

packages/agent-core/src/config/schema.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,10 @@ const McpServerConfigDiscriminatedSchema = z.discriminatedUnion('transport', [
335335

336336
export const McpServerConfigSchema = z.preprocess((raw) => {
337337
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return raw;
338-
const obj = raw as Record<string, unknown>;
338+
let obj = { ...raw } as Record<string, unknown>;
339+
if ('disabled' in obj && typeof obj['disabled'] === 'boolean') {
340+
obj['enabled'] = !obj['disabled'];
341+
}
339342
if ('transport' in obj) return obj;
340343
if (typeof obj['command'] === 'string') return { ...obj, transport: 'stdio' };
341344
if (typeof obj['url'] === 'string') return { ...obj, transport: 'http' };

packages/agent-core/src/config/toml.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,16 @@ function transformProviderData(data: Record<string, unknown>): Record<string, un
375375

376376
function transformModelData(data: Record<string, unknown>): Record<string, unknown> {
377377
const out = transformPlainObject(data);
378+
// Handle maxOutputSize migration from maxOutputTokens/maxTokens
379+
if (!('maxOutputSize' in out)) {
380+
if ('maxOutputTokens' in out && typeof out['maxOutputTokens'] === 'number') {
381+
out['maxOutputSize'] = out['maxOutputTokens'];
382+
} else if ('maxTokens' in out && typeof out['maxTokens'] === 'number') {
383+
out['maxOutputSize'] = out['maxTokens'];
384+
}
385+
}
386+
387+
// Transform overrides if present to ensure consistent key format
378388
if (isPlainObject(out['overrides'])) {
379389
out['overrides'] = transformPlainObject(out['overrides']);
380390
}

packages/agent-core/src/mcp/connection-manager.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { StdioMcpClient } from './client-stdio';
1313
import { toMcpServerConfigView, type McpServerConfigView } from './config-view';
1414
import type { McpOAuthService } from './oauth';
1515
import type { McpRegistryEntry, McpServerSource } from './registry';
16+
import { sanitizeMcpSchema } from './schema-sanitize';
1617
import { assertMcpInputSchema, type MCPClient, type MCPToolDefinition } from './types';
1718

1819
export type McpServerStatus = 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth';
@@ -594,11 +595,14 @@ export class McpConnectionManager {
594595
const mcpTools = await client.listTools();
595596
return {
596597
rawTools: mcpTools,
597-
tools: mcpTools.map((mcpTool) => ({
598-
name: mcpTool.name,
599-
description: mcpTool.description,
600-
parameters: assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema),
601-
})),
598+
tools: mcpTools.map((mcpTool) => {
599+
const validated = assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema);
600+
return {
601+
name: mcpTool.name,
602+
description: mcpTool.description,
603+
parameters: sanitizeMcpSchema(validated),
604+
};
605+
}),
602606
};
603607
}
604608

packages/agent-core/src/mcp/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * from './connection-manager';
33
export * from './global-config';
44
export * from './oauth';
55
export * from './registry';
6+
export * from './schema-sanitize';
67
export * from './session-config';
78
export * from './tool-naming';
89
export * from './types';
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
/**
2+
* Sanitize standard JSON Schemas emitted by MCP servers into the stricter
3+
* "Moonshot Flavored JSON Schema" (MFJS) the Kimi API validator expects.
4+
*
5+
* ## Background
6+
*
7+
* MCP servers advertise tool input schemas as standard JSON Schema objects.
8+
* Standard JSON Schema permits property schemas that omit the `type` keyword
9+
* (e.g. `{"enum": ["a", "b"]}`) and freely uses combinators (`anyOf`,
10+
* `oneOf`, `allOf`) and `$ref` indirection. Most LLM providers (OpenAI,
11+
* Anthropic) accept these without issue.
12+
*
13+
* Moonshot's validator is stricter: every property must carry an explicit
14+
* `type`, and `$ref` pointers must be resolved inline. Without sanitization
15+
* the API returns HTTP 400:
16+
*
17+
* > tools.function.parameters is not a valid moonshot flavored json schema,
18+
* > details: <At path 'properties.X': type is not defined>
19+
*
20+
* This module is a TypeScript port of the original kosong interceptor that
21+
* shipped in the Python-based kimi-cli (`kosong/utils/jsonschema.py`).
22+
*
23+
* ## What it does
24+
*
25+
* 1. **Dereferences local `$ref`** entries (`#/$defs/...`) so the resolved
26+
* schema contains no indirection, then strips the definition buckets.
27+
* 2. **Fills in missing `type`** on every property schema — inferred from
28+
* `enum`/`const` values, from structural keywords (`properties` →
29+
* `"object"`, `items` → `"array"`, etc.), or defaulting to `"string"`.
30+
*
31+
* Combinator branches (`anyOf`/`oneOf`/`allOf`/`$ref`/`not`/`if`/`then`/
32+
* `else`) are left alone because they legitimately describe shape without
33+
* `type`.
34+
*/
35+
36+
type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
37+
type JsonRecord = Record<string, Json>;
38+
39+
/**
40+
* JSON Schema keywords that describe a property's shape without (or in
41+
* addition to) a `type` keyword. When any of these are present we skip the
42+
* type-filling step so we don't distort the schema's meaning.
43+
*/
44+
const COMBINATOR_KEYS = [
45+
'anyOf',
46+
'oneOf',
47+
'allOf',
48+
'not',
49+
'if',
50+
'then',
51+
'else',
52+
'$ref',
53+
] as const;
54+
55+
const OBJECT_KEYWORDS = [
56+
'properties',
57+
'additionalProperties',
58+
'patternProperties',
59+
'propertyNames',
60+
'required',
61+
'minProperties',
62+
'maxProperties',
63+
] as const;
64+
65+
const ARRAY_KEYWORDS = [
66+
'items',
67+
'prefixItems',
68+
'minItems',
69+
'maxItems',
70+
'uniqueItems',
71+
'contains',
72+
] as const;
73+
74+
const STRING_KEYWORDS = ['minLength', 'maxLength', 'pattern', 'format'] as const;
75+
76+
const NUMERIC_KEYWORDS = [
77+
'minimum',
78+
'maximum',
79+
'multipleOf',
80+
'exclusiveMinimum',
81+
'exclusiveMaximum',
82+
] as const;
83+
84+
/**
85+
* Resolve local `$ref` entries inside a JSON Schema, then return a deep copy
86+
* with every reference inlined and the definition buckets removed.
87+
*
88+
* Only local references (those starting with `#`) are resolved; remote
89+
* references (e.g. `https://...`) are left untouched.
90+
*
91+
* @throws if a local `$ref` cannot be resolved or resolves to a non-object.
92+
*/
93+
function derefJsonSchema(schema: JsonRecord): JsonRecord {
94+
const root = structuredClone(schema);
95+
96+
function resolvePointer(pointer: string): Json {
97+
const pathStr = pointer.replace(/^#\/?/, '');
98+
if (pathStr === '') {
99+
return root;
100+
}
101+
const parts = pathStr.split('/');
102+
let current: Json = root;
103+
for (const part of parts) {
104+
if (typeof current !== 'object' || current === null || Array.isArray(current)) {
105+
throw new Error(`Unable to resolve reference path: ${pointer}`);
106+
}
107+
current = (current as JsonRecord)[part] ?? null;
108+
if (current === undefined) {
109+
throw new Error(`Unable to resolve reference path: ${pointer}`);
110+
}
111+
}
112+
return current;
113+
}
114+
115+
function traverse(node: Json, activeRefs: Set<string> = new Set()): Json {
116+
if (Array.isArray(node)) {
117+
return node.map((item) => traverse(item, activeRefs));
118+
}
119+
if (typeof node !== 'object' || node === null) {
120+
return node;
121+
}
122+
const record = node as JsonRecord;
123+
if (typeof record['$ref'] === 'string') {
124+
const ref = record['$ref'];
125+
if (ref.startsWith('#')) {
126+
if (activeRefs.has(ref)) {
127+
return { type: 'object', description: 'Circular reference' };
128+
}
129+
const nextActive = new Set(activeRefs);
130+
nextActive.add(ref);
131+
const target = traverse(resolvePointer(ref), nextActive);
132+
if (typeof target !== 'object' || target === null || Array.isArray(target)) {
133+
throw new Error('Local $ref must resolve to a JSON object');
134+
}
135+
const { $ref: _, ...rest } = record;
136+
return { ...(target as JsonRecord), ...rest };
137+
}
138+
// Remote reference — leave as-is.
139+
return record;
140+
}
141+
const result: JsonRecord = {};
142+
for (const [key, value] of Object.entries(record)) {
143+
result[key] = traverse(value, activeRefs);
144+
}
145+
return result;
146+
}
147+
148+
const resolved = traverse(root) as JsonRecord;
149+
delete resolved['$defs'];
150+
delete resolved['definitions'];
151+
return resolved;
152+
}
153+
154+
/**
155+
* Walk into every property-schema position under `node` and ensure each
156+
* declares a `type`. Mutates the node in place (the caller should pass a
157+
* deep clone).
158+
*
159+
* Property-schema positions are: values under `properties`, entries in
160+
* `items` (object or array form), `additionalProperties` (object form), and
161+
* branches of `anyOf`/`oneOf`/`allOf`.
162+
*
163+
* `node` itself is treated as a container and is not normalized — only the
164+
* property schemas it contains are.
165+
*/
166+
function recurseSchema(node: Json): void {
167+
if (typeof node !== 'object' || node === null || Array.isArray(node)) return;
168+
const record = node as JsonRecord;
169+
170+
const props = record['properties'];
171+
if (typeof props === 'object' && props !== null && !Array.isArray(props)) {
172+
for (const value of Object.values(props as JsonRecord)) {
173+
normalizeProperty(value);
174+
}
175+
}
176+
177+
const items = record['items'];
178+
if (typeof items === 'object' && items !== null) {
179+
if (Array.isArray(items)) {
180+
for (const value of items) normalizeProperty(value);
181+
} else {
182+
normalizeProperty(items);
183+
}
184+
}
185+
186+
const additional = record['additionalProperties'];
187+
if (typeof additional === 'object' && additional !== null && !Array.isArray(additional)) {
188+
normalizeProperty(additional);
189+
}
190+
191+
for (const key of ['anyOf', 'oneOf', 'allOf'] as const) {
192+
const branches = record[key];
193+
if (Array.isArray(branches)) {
194+
for (const value of branches) normalizeProperty(value);
195+
}
196+
}
197+
}
198+
199+
/**
200+
* Ensure `node` (a property schema) declares a `type`, then recurse into it.
201+
*/
202+
function normalizeProperty(node: Json): void {
203+
if (typeof node !== 'object' || node === null || Array.isArray(node)) return;
204+
const record = node as JsonRecord;
205+
206+
if (!('type' in record) && !COMBINATOR_KEYS.some((key) => key in record)) {
207+
const enumValues = record['enum'];
208+
if (Array.isArray(enumValues) && enumValues.length > 0) {
209+
record['type'] = inferTypeFromValues(enumValues);
210+
} else if ('const' in record) {
211+
record['type'] = inferTypeFromValues([record['const']]);
212+
} else {
213+
record['type'] = inferTypeFromStructure(record);
214+
}
215+
}
216+
217+
recurseSchema(record);
218+
}
219+
220+
/**
221+
* Infer a JSON Schema `type` from structural keywords present on `node`.
222+
*
223+
* Falls back to `"string"` only when the node carries no structural hints.
224+
*/
225+
function inferTypeFromStructure(node: JsonRecord): string {
226+
if (OBJECT_KEYWORDS.some((k) => k in node)) return 'object';
227+
if (ARRAY_KEYWORDS.some((k) => k in node)) return 'array';
228+
if (STRING_KEYWORDS.some((k) => k in node)) return 'string';
229+
if (NUMERIC_KEYWORDS.some((k) => k in node)) return 'number';
230+
return 'string';
231+
}
232+
233+
/**
234+
* Infer a JSON Schema `type` string from a list of concrete values.
235+
*
236+
* - Single type → return it.
237+
* - `{integer, number}` → `"number"` (integer is a subset of number).
238+
* - Mixed → `"string"`.
239+
*/
240+
function inferTypeFromValues(values: Json[]): string {
241+
const inferred = new Set<string>();
242+
for (const value of values) {
243+
if (typeof value === 'boolean') inferred.add('boolean');
244+
else if (typeof value === 'number') {
245+
inferred.add(Number.isInteger(value) ? 'integer' : 'number');
246+
} else if (typeof value === 'string') inferred.add('string');
247+
else if (value === null) inferred.add('null');
248+
else if (Array.isArray(value)) inferred.add('array');
249+
else if (typeof value === 'object') inferred.add('object');
250+
else return 'string';
251+
}
252+
if (inferred.size === 1) return [...inferred][0]!;
253+
if (inferred.size === 2 && inferred.has('integer') && inferred.has('number')) return 'number';
254+
return 'string';
255+
}
256+
257+
/**
258+
* Sanitize a standard JSON Schema (as emitted by MCP servers) into
259+
* Moonshot Flavored JSON Schema: resolve local `$ref` pointers and fill in
260+
* missing `type` declarations on every property.
261+
*
262+
* Returns a **new** object; the input is never mutated. Non-object inputs
263+
* are returned unchanged so callers can use this as an identity pass-through
264+
* for edge cases (MCP servers occasionally emit `true` or `false` as a
265+
* schema).
266+
*/
267+
export function sanitizeMcpSchema(schema: unknown): Record<string, unknown> {
268+
if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) {
269+
return schema as Record<string, unknown>;
270+
}
271+
const dereffed = derefJsonSchema(schema as JsonRecord);
272+
const cloned = structuredClone(dereffed);
273+
recurseSchema(cloned);
274+
return cloned;
275+
}

packages/agent-core/test/config/configs.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,28 @@ hooks = [{ type = "pre-tool-call", command = "echo hi" }]
497497
ErrorCodes.CONFIG_INVALID,
498498
);
499499
});
500+
501+
it('maps deprecated max_tokens and max_output_tokens to maxOutputSize', () => {
502+
const tomlWithMaxTokens = `
503+
[models.test]
504+
provider = "managed:kimi-code"
505+
model = "test-model"
506+
max_context_size = 128000
507+
max_tokens = 4096
508+
`;
509+
const configWithMaxTokens = parseConfigString(tomlWithMaxTokens, 'config.toml');
510+
expect(configWithMaxTokens.models?.['test']?.maxOutputSize).toBe(4096);
511+
512+
const tomlWithMaxOutputTokens = `
513+
[models.test]
514+
provider = "managed:kimi-code"
515+
model = "test-model"
516+
max_context_size = 128000
517+
max_output_tokens = 8192
518+
`;
519+
const configWithMaxOutputTokens = parseConfigString(tomlWithMaxOutputTokens, 'config.toml');
520+
expect(configWithMaxOutputTokens.models?.['test']?.maxOutputSize).toBe(8192);
521+
});
500522
});
501523

502524
describe('harness config schema and patch merge', () => {

0 commit comments

Comments
 (0)