Skip to content
Open
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';
297 changes: 297 additions & 0 deletions packages/agent-core/src/mcp/schema-sanitize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
/**
* 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. **Preserves tuple `items` arrays** as draft 2020-12 `prefixItems` (plus
* `minItems`/`maxItems`) so positional validation is kept while avoiding
* draft-07 array-form `items`, which Moonshot rejects.
*
* 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 (Array.isArray(current)) {
// RFC 6901: array index segments (e.g. #/$defs/X/anyOf/0).
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<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');
}
// Draft 2020-12 allows sibling keywords alongside $ref — they must be
// traversed too so nested local $refs are inlined before $defs drop.
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 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)) {
// Draft-07 tuple form: preserve positional semantics via prefixItems
// instead of collapsing to a homogeneous anyOf union.
for (const value of items) normalizeProperty(value);
record['prefixItems'] = items;
record['minItems'] = items.length;
record['maxItems'] = items.length;
delete record['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);
}
}
}

/**
* 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;
}
Loading