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-v2": patch
"@moonshot-ai/kimi-code": patch
---

Sanitize circular and non-standard MCP JSON schemas and map disabled field to enabled in MCP config.
5 changes: 4 additions & 1 deletion packages/agent-core-v2/src/mcpCore/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,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<string, unknown>;
const obj = { ...(raw as Record<string, unknown>) };
if ('disabled' in obj && !('enabled' 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' };
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-core-v2/src/mcpCore/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 '#/mcpCore/oauth/service';
import { sanitizeMcpSchema } from './schema-sanitize';
import { assertMcpInputSchema, type MCPClient, type MCPToolDefinition } from './types';

export type McpServerStatus = 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth' | 'removed';
Expand Down Expand Up @@ -451,7 +452,7 @@ export class McpConnectionManager implements McpConnectionView {
tools: mcpTools.map((mcpTool) => ({
name: mcpTool.name,
description: mcpTool.description,
parameters: assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema),
parameters: sanitizeMcpSchema(assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep Kimi-only normalization out of MCP discovery

When using Anthropic, OpenAI, or Google with an MCP tool, this stores the Kimi-specific rewrite in the shared Tool.parameters; those request formatters and the tool-argument validator subsequently consume the rewritten schema unchanged. Valid schemas such as a typeless arbitrary-value property are therefore narrowed to string, and tuple schemas are altered even though those providers can accept the original schema, causing models to generate or local validation to reject otherwise valid arguments. Preserve the MCP schema here and apply this compatibility normalization only in the Kimi request path.

Useful? React with 👍 / 👎.

})),
};
}
Expand Down
237 changes: 237 additions & 0 deletions packages/agent-core-v2/src/mcpCore/schema-sanitize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
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;

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)) {
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('#')) {

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 or preserve plain-name fragment references

When a valid modern JSON Schema uses $anchor: 'node' and $ref: '#node', this condition treats the plain-name fragment as a JSON Pointer; resolvePointer then looks for a top-level node property and throws if it is absent. Because sanitization runs during discovery, one such tool marks the entire MCP connection failed, including for non-Kimi providers. Restrict pointer resolution to # and #/..., or implement $anchor lookup and preserve unsupported fragments.

Useful? React with 👍 / 👎.

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

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 {
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 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)) {
for (const value of items) normalizeProperty(value);
if (items.length === 0) {
delete record['items'];
} else if (items.length === 1) {
record['items'] = items[0] as Json;
} else {
record['items'] = { anyOf: items };

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 tuple positions when replacing array-form items

For a draft-07 tuple such as items: [{type: 'number'}, {type: 'string'}], replacing items with a single anyOf schema allows either type at every position instead of preserving each positional constraint. It also makes an accompanying additionalItems: false ineffective, so extra elements become valid. This can let the model and local argument validator accept calls the MCP server rejects; convert to an equivalent positional representation rather than collapsing the branches.

Useful? React with 👍 / 👎.

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

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