diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 2bc4680ed6..b4bf29bce2 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -1707,7 +1707,9 @@ JSON Schema: - Annotation prose leaves the advertised form: nested property descriptions, literal examples, defaults, and titles are dropped. The root keeps its - description because it states the accepted input groups. + description because it states the accepted input groups. A root `detail` + property keeps its description because it selects between complete Evidence + and a bounded summary. - Schema-local references are inlined within the budget. Kimi Code expands references before sending provider requests, so sharing definitions cannot establish the provider-facing size. Expansion stops at the budget before diff --git a/src/contracts/compactInputSchema.ts b/src/contracts/compactInputSchema.ts index e7ec54ae53..d45329674f 100644 --- a/src/contracts/compactInputSchema.ts +++ b/src/contracts/compactInputSchema.ts @@ -2,6 +2,7 @@ import { COMPACT_SCHEMA_MAX_DEPTH, hasGatewayIncompatibleShape, inlineWithinBudget, + isSchemaObject, reduceToShallowProperties, schemaDepth, serializedBytes, @@ -28,6 +29,34 @@ export interface CompactedInputSchema { const INPUT_REDUCED_ANNOTATION = "Advertised in reduced form by the compact input schema profile; the server still validates complete canonical inputs."; +// `detail` selects between complete Evidence and a bounded summary. Without +// its guidance, callers default to complete replies that can exceed context. +const retainedRootPropertyDescriptions = ["detail"]; + +/** Restore the description of root properties whose choice bounds the reply. */ +const retainRootPropertyGuidance = ( + schema: Record, + stripped: Record, +): Record => { + if ( + !isSchemaObject(schema.properties) || + !isSchemaObject(stripped.properties) + ) + return stripped; + const properties = { ...stripped.properties }; + for (const name of retainedRootPropertyDescriptions) { + const source = schema.properties[name]; + const kept = properties[name]; + if ( + isSchemaObject(source) && + isSchemaObject(kept) && + typeof source.description === "string" + ) + properties[name] = { ...kept, description: source.description }; + } + return { ...stripped, properties }; +}; + /** * Render an advertised input schema within a per-schema byte budget for * providers that validate the literal serialized schema size. Validation @@ -41,7 +70,9 @@ export const compactAdvertisedInputSchema = ( ): CompactedInputSchema => { const expanded = inlineWithinBudget(stripAnnotations(schema), budgetBytes); const inline = - expanded === undefined ? undefined : typeArrayItemUnions(expanded); + expanded === undefined + ? undefined + : typeArrayItemUnions(retainRootPropertyGuidance(schema, expanded)); if ( inline !== undefined && serializedBytes(inline) <= budgetBytes && diff --git a/tests/boundary/mcp/compactInputSchemaMcp.test.ts b/tests/boundary/mcp/compactInputSchemaMcp.test.ts index 8b916cca73..87a3d53554 100644 --- a/tests/boundary/mcp/compactInputSchemaMcp.test.ts +++ b/tests/boundary/mcp/compactInputSchemaMcp.test.ts @@ -644,3 +644,30 @@ it.each(["full", "compact"] as const)( expect(calls).toBe(1); }, ); + +it("keeps root detail guidance so callers can choose bounded summaries", async () => { + const full = fullServer(); + const compact = compactServer(); + registerCatalog(full); + registerCatalog(compact); + const [fullTools, compactTools] = await Promise.all([ + listToolsThroughTransport(full), + listToolsThroughTransport(compact), + ]); + const detailGuidance = (tools: readonly AdvertisedTool[]) => + Object.fromEntries( + tools.flatMap(({ name, inputSchema }) => { + const detail = ( + inputSchema.properties as Record> + )?.detail; + return typeof detail?.description === "string" + ? [[name, detail.description]] + : []; + }), + ); + const expected = detailGuidance(fullTools); + expect(Object.keys(expected)).toEqual( + expect.arrayContaining(["inspect_artifact", "inspect_macho"]), + ); + expect(detailGuidance(compactTools)).toEqual(expected); +});