Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/mfjs-schema-sanitize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---

Sanitize MCP tool JSON Schemas for Moonshot's stricter validator: resolve local `$ref` (including circular), fill missing `type`, normalize tuple `items`, and map common `disabled` / `max_tokens` config aliases.
7 changes: 6 additions & 1 deletion packages/agent-core/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,12 @@ 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<string, unknown>;
let obj = { ...raw } as Record<string, unknown>;
// Standard MCP-ish configs use `disabled`; Kimi uses `enabled`.
if ('disabled' in obj && typeof obj['disabled'] === 'boolean' && !('enabled' in obj)) {
obj['enabled'] = !obj['disabled'];
delete 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' };
Expand Down
10 changes: 10 additions & 0 deletions packages/agent-core/src/config/toml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,16 @@ function transformProviderData(data: Record<string, unknown>): Record<string, un

function transformModelData(data: Record<string, unknown>): Record<string, unknown> {
const out = transformPlainObject(data);

// Compatibility: accept standard max_tokens / max_output_tokens aliases.
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'];
}
}

if (isPlainObject(out['overrides'])) {
out['overrides'] = transformPlainObject(out['overrides']);
}
Expand Down
14 changes: 9 additions & 5 deletions packages/agent-core/src/mcp/connection-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, type MCPToolDefinition } from './types';

export type McpServerStatus = 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth';
Expand Down Expand Up @@ -465,11 +466,14 @@ export class McpConnectionManager {
const mcpTools = await client.listTools();
return {
rawTools: mcpTools,
tools: mcpTools.map((mcpTool) => ({
name: mcpTool.name,
description: mcpTool.description,
parameters: assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema),
})),
tools: mcpTools.map((mcpTool) => {
const validated = assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema);
return {
name: mcpTool.name,
description: mcpTool.description,
parameters: sanitizeMcpSchema(validated),
};
}),
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './connection-manager';
export * from './global-config';
export * from './oauth';
export * from './schema-sanitize';
export * from './session-config';
export * from './tool-naming';
export * from './types';
269 changes: 269 additions & 0 deletions packages/agent-core/src/mcp/schema-sanitize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
/**
* 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: <At path 'properties.X': type is not defined>
*
* 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.
* JSON Pointer segments are unescaped (`~1` → `/`, `~0` → `~`).
* 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"`.
* Mixed-type enums become `anyOf` of typed enum branches so no value is
* invalidated by a single forced `type`.
* 3. **Normalizes tuple `items` arrays** into `{ anyOf: items }` objects.
*
* Combinator branches (`anyOf`/`oneOf`/`allOf`/`$ref`/`not`/`if`/`then`/
* `else`) are left alone at their own level because they legitimately
* describe shape without `type`.
*/

type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
type JsonRecord = Record<string, Json>;

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;

/** Decode one JSON Pointer segment per RFC 6901. */
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 (typeof current !== 'object' || current === null || Array.isArray(current)) {
throw new Error(`Unable to resolve reference path: ${pointer}`);
Comment on lines +109 to +110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve array segments in local JSON Pointers

For a valid local reference such as #/$defs/Choice/anyOf/0, traversal reaches the anyOf array before processing segment 0, and this condition throws instead of resolving the array index. The exception propagates through connectAndDiscoverTools, causing connectOne to mark the entire MCP server failed, so one such schema prevents all of that server's tools from loading. Handle numeric RFC 6901 array segments during pointer resolution.

Useful? React with 👍 / 👎.

}
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<string> = 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 };
Comment on lines +140 to +141

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Dereference schema content in $ref siblings

When a draft 2020-12 $ref node has sibling schema content containing another local reference, the sibling values in rest are merged without passing through traverse. The root definition buckets are subsequently deleted, leaving that nested reference dangling; Ajv then fails to compile the tool schema and every invocation is rejected. Traverse the sibling values before merging them into the resolved target.

Useful? React with 👍 / 👎.

}
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 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);
record['items'] = { anyOf: items };
Comment on lines +171 to +173

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve positional semantics when normalizing tuple items

When an MCP server advertises a draft-07 tuple such as items: [{ type: 'string' }, { type: 'number' }], replacing it with items: { anyOf: ... } changes the contract from positional validation to allowing either type at every position. Since the resulting Tool.parameters is also compiled by validateExecutableToolArgs, malformed calls such as [1, 'x'] now pass local validation and reach the MCP server. Preserve the original tuple semantics rather than converting it to a homogeneous union.

Useful? React with 👍 / 👎.

} 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);
}
}
}

/**
* Split a mixed-type enum into typed `anyOf` branches so each member keeps a
* compatible `type` (JSON Schema applies `type` and `enum` together).
*/
function splitMixedEnum(values: Json[]): JsonRecord[] {
const buckets = new Map<string, Json[]>();
for (const value of values) {
const t = jsonTypeOf(value);
const list = buckets.get(t) ?? [];
list.push(value);
buckets.set(t, list);
}
// integer is a subset of number — merge when both present
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 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 {
// Mixed types: replace bare enum with typed anyOf branches.
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 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';
}

/**
* 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<string, unknown> {
if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) {
return schema as Record<string, unknown>;
}
const dereffed = derefJsonSchema(schema as JsonRecord);
const cloned = structuredClone(dereffed);
recurseSchema(cloned);
return cloned;
}
22 changes: 22 additions & 0 deletions packages/agent-core/test/config/configs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,28 @@ micro_compaction = false
]);
});

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);
});

it('accepts maxOutputSize on a model alias and round-trips it', () => {
const parsed = KimiConfigSchema.parse({
providers: { local: { type: 'anthropic', apiKey: 'sk-test' } },
Expand Down
Loading