From b105dae7c6ea63f0dfc0f67f6be29b17ca078c99 Mon Sep 17 00:00:00 2001 From: Cipher <268238676+iamcipherdev@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:47:03 +0500 Subject: [PATCH] test(mcp): pin Kimi/Moonshot-accepted input schemas for #9581 (#9592) * test(mcp): pin Kimi/Moonshot-accepted input schemas for #9581 Closes #9581 #9581 reports Kimi/Moonshot rejecting 9 tools whose input schemas carried a $ref to a oneOf/anyOf with a nested $ref. The inlined advertisement that fixes this is already on main (reused: "inline" in toolInputSchemaWithMetadata, 554816be, unreleased): all 246 advertised input schemas are reference-free in both profiles. This adds the regression coverage that pins it: the issue's minimal trigger through the compact presenter, and the rejected reference pattern checked across every served input schema in both profiles, naming the nine tools from the issue. Not verified against live Kimi/Moonshot (no access); verified locally that no advertised input schema in either profile contains the rejected pattern. * test(javascript): distinguish package scope in route fixtures * test(apple): separate identity and refusal fixture groups * test(mcp): qualify reference detector and semantic inlining --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/contracts/compactInputSchema.test.ts | 53 ++++++ .../mcp/compactInputSchemaMcp.test.ts | 164 ++++++++++++++++++ 2 files changed, 217 insertions(+) diff --git a/src/contracts/compactInputSchema.test.ts b/src/contracts/compactInputSchema.test.ts index 323397d206..8c389aa078 100644 --- a/src/contracts/compactInputSchema.test.ts +++ b/src/contracts/compactInputSchema.test.ts @@ -224,6 +224,59 @@ describe("compact advertised input schema", () => { }); }); +it("inlines the Kimi/Moonshot-rejected reference pattern from #9581", () => { + // Minimal trigger from morluto/rea#9581: Moonshot rejects a $ref whose + // target is a oneOf whose branches contain another $ref ("detected + // infinite recursion without termination condition"), even though none + // of these schemas actually recurse. The compact presentation inlines + // the whole shape, removing the locally reproduced rejected reference pattern. + const trigger = { + type: "object", + properties: { c: { $ref: "#/$defs/u" } }, + $defs: { + u: { + oneOf: [ + { + type: "object", + properties: { a: { $ref: "#/$defs/s" } }, + required: ["a"], + }, + { + type: "object", + properties: { a: { $ref: "#/$defs/t" } }, + required: ["a"], + }, + ], + }, + s: { type: "string" }, + t: { type: "number" }, + }, + }; + const { schema, presentation } = compactAdvertisedInputSchema( + trigger, + // COMPACT_INPUT_SCHEMA_BUDGET_BYTES (13 KiB); the contracts layer + // cannot import the config layer, so the budget stays literal here. + 13 * 1024, + ); + expect(presentation).toBe("inline"); + expect(schema.$defs).toBeUndefined(); + expect(JSON.stringify(schema)).not.toContain('"$ref":'); + const ajv = new Ajv2020({ strict: false }); + const canonical = ajv.compile(trigger); + const compact = ajv.compile(schema); + for (const [input, expected] of [ + [{ c: { a: "text" } }, true], + [{ c: { a: 7 } }, true], + [{ c: { a: false } }, false], + [{ c: {} }, false], + [{}, true], + [{ c: null }, false], + ] as const) { + expect(canonical(input)).toBe(expected); + expect(compact(input)).toBe(expected); + } +}); + describe("strict gateway compatibility", () => { it("types homogeneous union items without discarding their branches", () => { const original = { diff --git a/tests/boundary/mcp/compactInputSchemaMcp.test.ts b/tests/boundary/mcp/compactInputSchemaMcp.test.ts index 87a3d53554..62d8c43238 100644 --- a/tests/boundary/mcp/compactInputSchemaMcp.test.ts +++ b/tests/boundary/mcp/compactInputSchemaMcp.test.ts @@ -80,6 +80,121 @@ const unsupportedDependencyKeywords = ( }); }; +// Moonshot rejects a $ref whose target is a oneOf/anyOf whose branches +// contain another $ref (morluto/rea#9581): "detected infinite recursion +// without termination condition". The rejection is about the reference +// shape, not the union itself, so this checks the pattern rather than +// banning references outright: a genuinely recursive schema still needs +// $ref, but never in this shape. +const resolveLocalPointer = (root: unknown, reference: string): unknown => { + if (!reference.startsWith("#/")) return undefined; + let current: unknown = root; + for (const token of reference + .slice(2) + .split("/") + .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))) { + if (typeof current !== "object" || current === null) return undefined; + current = (current as Record)[token]; + } + return current; +}; + +const collectSchemaReferences = ( + node: unknown, + path: string, + collected: { path: string; reference: string }[], +): void => { + if (Array.isArray(node)) { + node.forEach((child, index) => + collectSchemaReferences(child, `${path}[${index}]`, collected), + ); + return; + } + if (typeof node !== "object" || node === null) return; + for (const [key, value] of Object.entries(node)) { + if (key === "$ref" && typeof value === "string") { + collected.push({ path: `${path}.$ref`, reference: value }); + continue; + } + if (["const", "enum", "default", "examples"].includes(key)) continue; + if (schemaMaps.has(key) && typeof value === "object" && value !== null) { + for (const [name, child] of Object.entries(value)) + collectSchemaReferences(child, `${path}.${key}.${name}`, collected); + } else if (schemaArrays.has(key) && Array.isArray(value)) { + value.forEach((child, index) => + collectSchemaReferences(child, `${path}.${key}[${index}]`, collected), + ); + } else if (schemaChildren.has(key)) { + if (Array.isArray(value)) + value.forEach((child, index) => + collectSchemaReferences(child, `${path}.${key}[${index}]`, collected), + ); + else collectSchemaReferences(value, `${path}.${key}`, collected); + } + } +}; + +/** Paths of $refs Moonshot rejects: targets that are a oneOf/anyOf with a nested $ref. */ +const moonshotRejectedReferences = ( + schema: Record, +): readonly string[] => { + const collected: { path: string; reference: string }[] = []; + collectSchemaReferences(schema, "$", collected); + return collected + .filter(({ reference }) => { + const target = resolveLocalPointer(schema, reference); + if (typeof target !== "object" || target === null) return false; + for (const key of ["oneOf", "anyOf"]) { + const branches = (target as Record)[key]; + if (!Array.isArray(branches)) continue; + const nested: { path: string; reference: string }[] = []; + for (const branch of branches) + collectSchemaReferences(branch, "$", nested); + if (nested.length > 0) return true; + } + return false; + }) + .map(({ path }) => path); +}; + +it("distinguishes rejected references from allowed references and literal data", () => { + const union = { + oneOf: [ + { type: "object", properties: { a: { $ref: "#/$defs/s" } } }, + { type: "object", properties: { a: { $ref: "#/$defs/t" } } }, + ], + }; + const definitions = { s: { type: "string" }, t: { type: "number" } }; + const rejected = { + type: "object", + properties: { c: { $ref: "#/$defs/u" } }, + $defs: { ...definitions, u: union }, + }; + expect(moonshotRejectedReferences(rejected)).toEqual(["$.properties.c.$ref"]); + expect( + moonshotRejectedReferences({ + type: "object", + properties: { c: { $ref: "#/$defs/s" } }, + $defs: definitions, + }), + ).toEqual([]); + expect( + moonshotRejectedReferences({ + type: "object", + properties: { c: { $ref: "#/$defs/a~1b~0c" } }, + $defs: { ...definitions, "a/b~c": union }, + }), + ).toEqual(["$.properties.c.$ref"]); + expect( + moonshotRejectedReferences({ + const: rejected, + enum: [rejected], + default: rejected, + examples: [rejected], + }), + ).toEqual([]); +}); + const listToolsThroughTransport = async ( server: EvidenceMcpServer, ): Promise => { @@ -482,6 +597,55 @@ it("retains parameter types and avoids client-side reference expansion", async ( }); }); +it("advertises Moonshot-accepted input schemas without the rejected reference pattern in both profiles", async () => { + // morluto/rea#9581: Kimi/Moonshot rejects a $ref whose target is a + // oneOf/anyOf with another $ref inside, which broke every Kimi request + // because clients send the whole tool list. The nine tools named in the + // issue must stay advertised with inlined schemas in both profiles. + const rejectedInIssue = [ + "capture_browser_scenario", + "capture_native_ui_scenario", + "observe_native_calls", + "inspect_analysis_view", + "compare_web_captures", + "trace_javascript_semantics", + "compare_process_captures", + "correlate_static_and_runtime", + "verify_reconstruction", + ]; + for (const profile of ["full", "compact"] as const) { + const server = profile === "full" ? fullServer() : compactServer(); + registerCatalog(server); + const tools = await listToolsThroughTransport(server); + const byName = new Map(tools.map((tool) => [tool.name, tool])); + for (const name of rejectedInIssue) { + const tool = byName.get(name); + if (tool === undefined) + throw new Error(`${profile} profile: ${name} was not advertised`); + expect( + tool.inputSchema, + `${profile} profile: ${name}`, + ).not.toHaveProperty("$defs"); + expect( + moonshotRejectedReferences(tool.inputSchema), + `${profile} profile: ${name}`, + ).toEqual([]); + } + expect( + tools.flatMap(({ name, inputSchema }) => + moonshotRejectedReferences(inputSchema).map( + (path) => `${profile} profile: ${name}${path.slice(1)}`, + ), + ), + ).toEqual([]); + expect( + tools + .filter(({ inputSchema }) => Object.hasOwn(inputSchema, "$defs")) + .map(({ name }) => `${profile} profile: ${name}`), + ).toEqual([]); + } +}); + it("parses the profile selection the server startup reads", () => { expect(parseMcpInputSchemaProfile("compact")).toEqual({ ok: true,