-
Notifications
You must be signed in to change notification settings - Fork 876
fix(agent-core/mcp): sanitize MCP JSON Schemas for Moonshot MFJS vali… #2242
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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'; |
| 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}`); | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a draft 2020-12 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an MCP server advertises a draft-07 tuple such as 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; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For a valid local reference such as
#/$defs/Choice/anyOf/0, traversal reaches theanyOfarray before processing segment0, and this condition throws instead of resolving the array index. The exception propagates throughconnectAndDiscoverTools, causingconnectOneto 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 👍 / 👎.