|
| 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 | +} |
0 commit comments