Skip to content

Commit c359747

Browse files
committed
fix(agent-core): resolve merge conflicts with current main
Cherry-pick PR MoonshotAI#830's changes onto current upstream main, resolving the merge conflict in transformModelData (toml.ts) introduced by main's overrides-transform refactor: - schema.ts: add incompatible 'disabled' -> 'enabled' preprocess mapping - toml.ts: keep the overrides transform and add the max_tokens / max_output_tokens -> maxOutputSize alias fallback - Add sanitizeMcpSchema (MFJS emitter) + cycle detector + root-pointer comment addressing Codex review feedback on PR MoonshotAI#830 - Integrate the sanitizer into connectAndDiscoverTools - Update schema-sanitize export in index.ts - Add tests + changeset
1 parent c5c6282 commit c359747

9 files changed

Lines changed: 752 additions & 8 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.

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

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

227227
export const McpServerConfigSchema = z.preprocess((raw) => {
228228
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return raw;
229-
const obj = raw as Record<string, unknown>;
229+
let obj = { ...raw } as Record<string, unknown>;
230+
if ('disabled' in obj && typeof obj['disabled'] === 'boolean') {
231+
obj['enabled'] = !obj['disabled'];
232+
}
230233
if ('transport' in obj) return obj;
231234
if (typeof obj['command'] === 'string') return { ...obj, transport: 'stdio' };
232235
if (typeof obj['url'] === 'string') return { ...obj, transport: 'http' };

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,15 @@ function transformModelData(data: Record<string, unknown>): Record<string, unkno
363363
if (isPlainObject(out['overrides'])) {
364364
out['overrides'] = transformPlainObject(out['overrides']);
365365
}
366+
// Map standard `max_tokens` / `max_output_tokens` aliases to Kimi's
367+
// `maxOutputSize` when the canonical field is absent.
368+
if (!('maxOutputSize' in out)) {
369+
if ('maxOutputTokens' in out && typeof out['maxOutputTokens'] === 'number') {
370+
out['maxOutputSize'] = out['maxOutputTokens'];
371+
} else if ('maxTokens' in out && typeof out['maxTokens'] === 'number') {
372+
out['maxOutputSize'] = out['maxTokens'];
373+
}
374+
}
366375
return out;
367376
}
368377

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { SseMcpClient } from './client-sse';
1111
import type { UnexpectedCloseReason } from './client-shared';
1212
import { StdioMcpClient } from './client-stdio';
1313
import type { McpOAuthService } from './oauth';
14+
import { sanitizeMcpSchema } from './schema-sanitize';
1415
import { assertMcpInputSchema, type MCPClient } from './types';
1516

1617
export type McpServerStatus = 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth';
@@ -42,7 +43,6 @@ type RuntimeMcpClient = StdioMcpClient | HttpMcpClient | SseMcpClient;
4243

4344
export interface McpConnectionManagerOptions {
4445
readonly envLookup?: (name: string) => string | undefined;
45-
readonly stdioCwd?: string;
4646
/**
4747
* Optional OAuth orchestrator. When provided, remote servers without a
4848
* static bearer token participate in the OAuth-via-synthetic-tool flow:
@@ -332,7 +332,7 @@ export class McpConnectionManager {
332332
private createClient(config: McpServerConfig, name: string): RuntimeMcpClient {
333333
const toolCallTimeoutMs = config.toolTimeoutMs;
334334
if (config.transport === 'stdio') {
335-
return new StdioMcpClient(config, { toolCallTimeoutMs, defaultCwd: this.options.stdioCwd });
335+
return new StdioMcpClient(config, { toolCallTimeoutMs });
336336
}
337337
if (config.transport === 'sse') {
338338
return new SseMcpClient(config, {
@@ -379,11 +379,14 @@ export class McpConnectionManager {
379379
private async connectAndDiscoverTools(client: RuntimeMcpClient): Promise<Tool[]> {
380380
await client.connect();
381381
const mcpTools = await client.listTools();
382-
return mcpTools.map((mcpTool) => ({
383-
name: mcpTool.name,
384-
description: mcpTool.description,
385-
parameters: assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema),
386-
}));
382+
return mcpTools.map((mcpTool) => {
383+
const validated = assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema);
384+
return {
385+
name: mcpTool.name,
386+
description: mcpTool.description,
387+
parameters: sanitizeMcpSchema(validated),
388+
};
389+
});
387390
}
388391

389392
private async closeClient(entry: InternalEntry): Promise<void> {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './connection-manager';
22
export * from './oauth';
3+
export * from './schema-sanitize';
34
export * from './session-config';
45
export * from './tool-naming';
56
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
@@ -588,6 +588,28 @@ micro_compaction = false
588588
}),
589589
).toThrow();
590590
});
591+
592+
it('maps deprecated max_tokens and max_output_tokens to maxOutputSize', () => {
593+
const tomlWithMaxTokens = `
594+
[models.test]
595+
provider = "managed:kimi-code"
596+
model = "test-model"
597+
max_context_size = 128000
598+
max_tokens = 4096
599+
`;
600+
const configWithMaxTokens = parseConfigString(tomlWithMaxTokens, 'config.toml');
601+
expect(configWithMaxTokens.models?.['test']?.maxOutputSize).toBe(4096);
602+
603+
const tomlWithMaxOutputTokens = `
604+
[models.test]
605+
provider = "managed:kimi-code"
606+
model = "test-model"
607+
max_context_size = 128000
608+
max_output_tokens = 8192
609+
`;
610+
const configWithMaxOutputTokens = parseConfigString(tomlWithMaxOutputTokens, 'config.toml');
611+
expect(configWithMaxOutputTokens.models?.['test']?.maxOutputSize).toBe(8192);
612+
});
591613
});
592614

593615
describe('config path env override', () => {

0 commit comments

Comments
 (0)