diff --git a/.changeset/mfjs-schema-sanitize.md b/.changeset/mfjs-schema-sanitize.md new file mode 100644 index 0000000000..d809f9cd57 --- /dev/null +++ b/.changeset/mfjs-schema-sanitize.md @@ -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. diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 0e4b7bcaa7..68a51a167c 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -327,7 +327,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; + let obj = { ...raw } as Record; + // 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' }; diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index 2aa143b09e..1146e825fb 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -375,6 +375,16 @@ function transformProviderData(data: Record): Record): Record { 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']); } diff --git a/packages/agent-core/src/mcp/connection-manager.ts b/packages/agent-core/src/mcp/connection-manager.ts index 152d3a8131..e32402ea91 100644 --- a/packages/agent-core/src/mcp/connection-manager.ts +++ b/packages/agent-core/src/mcp/connection-manager.ts @@ -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'; @@ -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), + }; + }), }; } diff --git a/packages/agent-core/src/mcp/index.ts b/packages/agent-core/src/mcp/index.ts index c497770760..913c7e26c8 100644 --- a/packages/agent-core/src/mcp/index.ts +++ b/packages/agent-core/src/mcp/index.ts @@ -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'; diff --git a/packages/agent-core/src/mcp/schema-sanitize.ts b/packages/agent-core/src/mcp/schema-sanitize.ts new file mode 100644 index 0000000000..d77f75feb0 --- /dev/null +++ b/packages/agent-core/src/mcp/schema-sanitize.ts @@ -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: + * + * 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; + +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 = 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(); + 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 { + if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) { + return schema as Record; + } + const dereffed = derefJsonSchema(schema as JsonRecord); + const cloned = structuredClone(dereffed); + recurseSchema(cloned); + return cloned; +} diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index 39612d53aa..9d256f7701 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -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' } }, diff --git a/packages/agent-core/test/mcp/config-loader.test.ts b/packages/agent-core/test/mcp/config-loader.test.ts index 754630b593..b06e32e016 100644 --- a/packages/agent-core/test/mcp/config-loader.test.ts +++ b/packages/agent-core/test/mcp/config-loader.test.ts @@ -250,6 +250,28 @@ describe('loadMcpServers', () => { }); }); + it('translates standard disabled=true into enabled=false', async () => { + const home = makeTempDir(); + const cwd = makeTempDir(); + await writeJson(join(home, 'mcp.json'), { + mcpServers: { + disabledServer: { command: 'node', disabled: true }, + enabledServer: { command: 'node', disabled: false }, + }, + }); + const servers = await loadMcpServers({ cwd, homeDir: home }); + expect(servers['disabledServer']).toEqual({ + transport: 'stdio', + command: 'node', + enabled: false, + }); + expect(servers['enabledServer']).toEqual({ + transport: 'stdio', + command: 'node', + enabled: true, + }); + }); + it('infers transport=http when an entry omits transport but has url', async () => { const home = makeTempDir(); const cwd = makeTempDir(); diff --git a/packages/agent-core/test/mcp/schema-sanitize.test.ts b/packages/agent-core/test/mcp/schema-sanitize.test.ts new file mode 100644 index 0000000000..6529e2d2a0 --- /dev/null +++ b/packages/agent-core/test/mcp/schema-sanitize.test.ts @@ -0,0 +1,476 @@ +import { describe, expect, it } from 'vitest'; + +import { sanitizeMcpSchema } from '../../src/mcp/schema-sanitize'; + +type Schema = Record; + +function props(result: Schema): Record { + return result['properties'] as Record; +} + +function prop(result: Schema, name: string): Schema { + return props(result)[name]!; +} + +describe('sanitizeMcpSchema — non-object inputs', () => { + it('returns null unchanged', () => { + expect(sanitizeMcpSchema(null)).toBe(null); + }); + + it('returns arrays unchanged (invalid schema, but identity)', () => { + expect(sanitizeMcpSchema([1, 2, 3])).toEqual([1, 2, 3]); + }); + + it('returns primitives unchanged', () => { + expect(sanitizeMcpSchema('string')).toBe('string'); + expect(sanitizeMcpSchema(42)).toBe(42); + expect(sanitizeMcpSchema(true)).toBe(true); + }); +}); + +describe('sanitizeMcpSchema — missing type filling', () => { + it('fills in "string" when no type and no structural hints', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + name: { description: 'User name' }, + }, + }); + expect(prop(result, 'name')['type']).toBe('string'); + }); + + it('infers type from enum values', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + mode: { enum: ['fast', 'slow'] }, + count: { enum: [1, 2, 3] }, + enabled: { enum: [true, false] }, + mixed: { enum: [1, 'a'] }, + }, + }); + expect(prop(result, 'mode')['type']).toBe('string'); + expect(prop(result, 'count')['type']).toBe('integer'); + expect(prop(result, 'enabled')['type']).toBe('boolean'); + // Mixed-type enums become typed anyOf branches (not a single forced type). + const mixed = prop(result, 'mixed'); + expect(mixed['type']).toBeUndefined(); + expect(mixed['enum']).toBeUndefined(); + const mixedBranches = mixed['anyOf'] as Schema[]; + expect(mixedBranches).toHaveLength(2); + const byType = Object.fromEntries(mixedBranches.map((b) => [b['type'], b['enum']])); + expect(byType['integer']).toEqual([1]); + expect(byType['string']).toEqual(['a']); + }); + + it('infers type from const value', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + kind: { const: 'user' }, + timeout: { const: 30 }, + }, + }); + expect(prop(result, 'kind')['type']).toBe('string'); + expect(prop(result, 'timeout')['type']).toBe('integer'); + }); + + it('infers type from structural keywords', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + objProp: { properties: { a: {} } }, + arrProp: { items: { type: 'string' } }, + strProp: { pattern: '^\\w+$' }, + numProp: { minimum: 0 }, + }, + }); + expect(prop(result, 'objProp')['type']).toBe('object'); + expect(prop(result, 'arrProp')['type']).toBe('array'); + expect(prop(result, 'strProp')['type']).toBe('string'); + expect(prop(result, 'numProp')['type']).toBe('number'); + }); + + it('does not add type when a combinator key is present', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + any: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + one: { oneOf: [{ type: 'string' }, { type: 'number' }] }, + all: { allOf: [{ type: 'string' }] }, + not: { not: { type: 'string' } }, + }, + }); + expect(prop(result, 'any')['type']).toBeUndefined(); + expect(prop(result, 'one')['type']).toBeUndefined(); + expect(prop(result, 'all')['type']).toBeUndefined(); + expect(prop(result, 'not')['type']).toBeUndefined(); + }); + + it('does not overwrite an existing type', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + name: { type: 'string', enum: ['a', 'b'] }, + }, + }); + expect(prop(result, 'name')['type']).toBe('string'); + }); +}); + +describe('sanitizeMcpSchema — $ref dereferencing', () => { + it('inlines local $ref pointers and strips $defs', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + user: { $ref: '#/$defs/User' }, + }, + $defs: { + User: { + type: 'object', + properties: { + name: {}, + age: {}, + }, + }, + }, + }); + + expect('$defs' in result).toBe(false); + const userProp = prop(result, 'user'); + expect(userProp['type']).toBe('object'); + const userProps = props(userProp); + expect(userProps['name']!['type']).toBe('string'); + expect(userProps['age']!['type']).toBe('string'); + }); + + it('preserves sibling keys alongside $ref', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + item: { $ref: '#/$defs/Item', description: 'overridden' }, + }, + $defs: { + Item: { type: 'string' }, + }, + }); + const itemProp = prop(result, 'item'); + expect(itemProp['type']).toBe('string'); + expect(itemProp['description']).toBe('overridden'); + }); + + it('handles nested $ref chains', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + a: { $ref: '#/$defs/A' }, + }, + $defs: { + A: { $ref: '#/$defs/B' }, + B: { type: 'integer' }, + }, + }); + expect(prop(result, 'a')['type']).toBe('integer'); + }); + + it('leaves remote $ref untouched', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + remote: { $ref: 'https://example.com/schema.json' }, + }, + }); + expect(prop(result, 'remote')['$ref']).toBe('https://example.com/schema.json'); + }); + + it('strips legacy "definitions" bucket', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: {}, + definitions: { Foo: { type: 'string' } }, + }); + expect('definitions' in result).toBe(false); + }); + + it('throws on unresolvable local $ref', () => { + expect(() => + sanitizeMcpSchema({ + type: 'object', + properties: { + broken: { $ref: '#/$defs/Missing' }, + }, + }), + ).toThrow(/Unable to resolve reference path/); + }); +}); + +describe('sanitizeMcpSchema — nested arrays and items', () => { + it('fills type on array items (object form)', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + tags: { type: 'array', items: { description: 'a tag' } }, + }, + }); + const tagsProp = prop(result, 'tags'); + expect((tagsProp['items'] as Schema)['type']).toBe('string'); + }); + + it('preserves tuple items as prefixItems (positional semantics)', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + pair: { type: 'array', items: [{ description: 'first' }, { type: 'number' }] }, + }, + }); + const pairProp = prop(result, 'pair'); + expect(pairProp['items']).toBeUndefined(); + const prefix = pairProp['prefixItems'] as Schema[]; + expect(prefix).toHaveLength(2); + expect(prefix[0]!['type']).toBe('string'); + expect(prefix[1]!['type']).toBe('number'); + expect(pairProp['minItems']).toBe(2); + expect(pairProp['maxItems']).toBe(2); + }); + + it('fills type on additionalProperties (object form)', () => { + const result = sanitizeMcpSchema({ + type: 'object', + additionalProperties: { description: 'dynamic value' }, + }); + expect((result['additionalProperties'] as Schema)['type']).toBe('string'); + }); +}); + +describe('sanitizeMcpSchema — regression: issue #792 (n8n anyOf)', () => { + // Reproduces the exact schema shape from issue #792 where n8n emits a + // property with `anyOf` whose branches lack explicit `type`. Moonshot's + // validator rejects this because `anyOf` branches must be objects. + it('fills type on anyOf/oneOf/allOf branches', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + pageSetup: { + type: 'object', + properties: { + size: { + anyOf: [ + { description: 'small' }, + { description: 'large', format: 'paper-size' }, + ], + }, + }, + }, + }, + }); + + const pageSetup = prop(result, 'pageSetup'); + const size = prop(pageSetup, 'size'); + const branches = size['anyOf'] as Schema[]; + expect(branches[0]!['type']).toBe('string'); + expect(branches[1]!['type']).toBe('string'); + }); + + it('recurses into anyOf branches that contain nested properties', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + config: { + anyOf: [ + { + properties: { + host: { description: 'hostname' }, + port: { minimum: 0 }, + }, + }, + ], + }, + }, + }); + + const config = prop(result, 'config'); + // The config property uses a combinator (anyOf), so no type is added at + // the config level. Inside the branch, nested properties get filled. + expect(config['type']).toBeUndefined(); + const branch = (config['anyOf'] as Schema[])[0]!; + const branchProps = props(branch); + expect(branchProps['host']!['type']).toBe('string'); + expect(branchProps['port']!['type']).toBe('number'); + }); +}); + +describe('sanitizeMcpSchema — immutability', () => { + it('never mutates the input schema', () => { + const input = { + type: 'object', + properties: { + name: { description: 'test' }, + }, + }; + const inputCopy = structuredClone(input); + sanitizeMcpSchema(input); + expect(input).toEqual(inputCopy); + }); +}); + +describe('sanitizeMcpSchema — integer/number unification', () => { + it('classifies {integer, number} mixed enum as "number"', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + value: { enum: [1, 2.5] }, + }, + }); + expect(prop(result, 'value')['type']).toBe('number'); + }); + + it('classifies pure integer enum as "integer"', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + value: { enum: [1, 2, 3] }, + }, + }); + expect(prop(result, 'value')['type']).toBe('integer'); + }); +}); + +describe('sanitizeMcpSchema — recursive / circular schemas', () => { + it('handles circular references without throwing or stack overflowing', () => { + const schema = { + type: 'object', + properties: { + children: { + type: 'array', + items: { + anyOf: [ + { type: 'string' }, + { $ref: '#/definitions/__schema0' }, + ], + }, + }, + }, + definitions: { + __schema0: { + type: 'object', + properties: { + name: { type: 'string' }, + children: { + type: 'array', + items: { + anyOf: [ + { type: 'string' }, + { $ref: '#/definitions/__schema0' }, + ], + }, + }, + }, + }, + }, + }; + + const result = sanitizeMcpSchema(schema); + + // Check that definitions got stripped + expect('definitions' in result).toBe(false); + + // Check that children exists + const children = prop(result, 'children'); + expect(children['type']).toBe('array'); + + // Verify circular reference was safely resolved to type object at the second level + const items = children['items'] as Schema; + const branches = items['anyOf'] as Schema[]; + expect(branches[0]!['type']).toBe('string'); + expect(branches[1]!['type']).toBe('object'); + + // Drill down to the circular reference + const nestedChildren = prop(branches[1], 'children'); + expect(nestedChildren['type']).toBe('array'); + const nestedItems = nestedChildren['items'] as Schema; + const nestedBranches = nestedItems['anyOf'] as Schema[]; + expect(nestedBranches[0]!['type']).toBe('string'); + expect(nestedBranches[1]!['type']).toBe('object'); + expect(nestedBranches[1]!['description']).toBe('Circular reference'); + }); + + it('handles root self-recursive references ($ref: "#") safely', () => { + const schema = { + type: 'object', + properties: { + self: { $ref: '#' }, + }, + }; + + const result = sanitizeMcpSchema(schema); + + // Verify circular reference at the root level was safely resolved at the nested level + const selfProp = prop(result, 'self'); + expect(selfProp['type']).toBe('object'); + + const nestedSelf = prop(selfProp, 'self'); + expect(nestedSelf['type']).toBe('object'); + expect(nestedSelf['description']).toBe('Circular reference'); + }); +}); + +describe('sanitizeMcpSchema — JSON Pointer escaping', () => { + it('decodes ~1 and ~0 in local $ref paths', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + slashy: { $ref: '#/$defs/a~1b' }, + tildy: { $ref: '#/$defs/c~0d' }, + }, + $defs: { + 'a/b': { type: 'integer' }, + 'c~d': { type: 'boolean' }, + }, + }); + expect(prop(result, 'slashy')['type']).toBe('integer'); + expect(prop(result, 'tildy')['type']).toBe('boolean'); + }); +}); + +describe('sanitizeMcpSchema — JSON Pointer array segments', () => { + it('resolves numeric segments into anyOf branches', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + choice: { $ref: '#/$defs/Choice/anyOf/0' }, + }, + $defs: { + Choice: { + anyOf: [{ type: 'integer' }, { type: 'string' }], + }, + }, + }); + expect(prop(result, 'choice')['type']).toBe('integer'); + }); +}); + +describe('sanitizeMcpSchema — $ref siblings', () => { + it('dereferences nested local $refs inside sibling keywords', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + wrapped: { + $ref: '#/$defs/Base', + properties: { + extra: { $ref: '#/$defs/Extra' }, + }, + }, + }, + $defs: { + Base: { type: 'object' }, + Extra: { type: 'boolean' }, + }, + }); + const wrapped = prop(result, 'wrapped'); + expect(wrapped['type']).toBe('object'); + expect('$defs' in result).toBe(false); + const extra = prop(wrapped, 'extra'); + expect(extra['type']).toBe('boolean'); + expect(extra['$ref']).toBeUndefined(); + }); +});