diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index b52ac6c331..3822adb5f8 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -36,6 +36,49 @@ after SDK conversion and check advertised validation and actual calls. Individual model APIs can impose additional nesting limits; complete producer captures can exceed ten structural levels. +### Compact input schema profile + +Some function-calling providers reject a request while any single tool's +`parameters` schema exceeds a literal serialized size cap; Moonshot clients +such as kimi-cli fail every request while the default catalog is attached +([#1484](https://github.com/morluto/rea/issues/1484)). Setting the server's +`REA_MCP_INPUT_SCHEMA_PROFILE` environment variable to `compact` renders every +advertised input schema within a 13,312-byte budget, measured to sit below +Moonshot's observed cap (community measurements: a 13.7 KB schema is accepted +while a ~15 KB one is rejected). The setting is read at startup; restart the +server to apply changes, as with `REA_MCP_MAX_RESPONSE_BYTES`. The default +value `full` keeps the complete advertisement unchanged. + +The compact profile renders the same tool set, output schemas, annotations, +and canonical server-side validation. It changes only the advertised input +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. +- 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 + allocating an oversized presentation. Unconstrained JSON fields advertise + all JSON value types explicitly so Kimi cannot infer string for Evidence + objects or arrays. +- The few schemas whose validation structure alone exceeds the budget + (`compare_web_captures`, `build_reconstruction_obligation_ledger`, + `evaluate_reconstruction_coverage`, `project_managed_application_graph`, + `compare_application_versions`, `capture_browser_scenario`) are advertised + in a reduced form: property names, reference-resolved types (including union + alternatives), first-level guidance, and the root's required-field constraints, + with nested validation detail elided. The + canonical schema still rejects malformed calls server-side, so callers + following the reduced advertisement receive the canonical typed error + instead of silent acceptance. + +Boundary tests pin every advertised compact schema to the budget, keep them +valid JSON Schema that still accepts every canonical example, and hold the +set of six reduced presentations as contracts evolve. Client verification +also checks provider-facing sizes and canonical examples after conversion by +the actual Kimi Code CLI; local captures do not establish Moonshot acceptance. + Self-contained output schemas advertise a content-bound `$id`, including their declared dialect. SDK validators can reuse compiled schemas across complete catalog refreshes and equivalent tool outputs; changing the schema changes its diff --git a/src/config/environment.ts b/src/config/environment.ts index f743de5761..3e02f68a28 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -5,11 +5,13 @@ import { ConfigurationError } from "../domain/configurationErrors.js"; import { err, ok, type Result } from "../domain/result.js"; import { analysisProviderSelectorSchema } from "../contracts/providerSelection.js"; import { ghidraStartupTimeoutSchema } from "./ghidraStartupTimeout.js"; +import { mcpInputSchemaProfileSchema } from "./mcpInputSchemaProfile.js"; import { mcpResponseBudgetSchema } from "./mcpResponseBudget.js"; const environmentSchema = z.object({ REA_ANALYSIS_PROVIDER: analysisProviderSelectorSchema.default("auto"), REA_MCP_MAX_RESPONSE_BYTES: mcpResponseBudgetSchema.optional(), + REA_MCP_INPUT_SCHEMA_PROFILE: mcpInputSchemaProfileSchema.optional(), REA_IDA_MCP_CONFIG: z .string() .min(1) diff --git a/src/config/mcpInputSchemaProfile.ts b/src/config/mcpInputSchemaProfile.ts new file mode 100644 index 0000000000..321803b5b6 --- /dev/null +++ b/src/config/mcpInputSchemaProfile.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { err, ok, type Result } from "../domain/result.js"; + +// Moonshot's function-calling API rejects requests while any single tool +// parameters schema exceeds its literal serialized size cap. Community +// measurements bound the cap between ~13.7 KB (accepted) and ~15 KB +// (rejected); 13 KiB leaves margin under the lower bound. See +// https://github.com/morluto/rea/issues/1484 for the measured evidence. +export const COMPACT_INPUT_SCHEMA_BUDGET_BYTES = 13 * 1024; + +export const mcpInputSchemaProfileSchema = z.enum(["full", "compact"]); + +const profileConstraint = + 'REA_MCP_INPUT_SCHEMA_PROFILE must be "full" (default) or "compact". The compact profile advertises input schemas without nested annotation prose for providers that cap each tool schema\'s serialized size.'; + +export type McpInputSchemaProfile = z.infer; + +/** Parse the advertised input schema presentation selection. */ +export const parseMcpInputSchemaProfile = ( + value: string | undefined, +): Result => { + if (value === undefined) return ok(undefined); + const parsed = mcpInputSchemaProfileSchema.safeParse(value); + return parsed.success + ? ok(parsed.data) + : err( + new ConfigurationError(profileConstraint, { + settings: [ + { + setting: "REA_MCP_INPUT_SCHEMA_PROFILE", + constraint: profileConstraint, + }, + ], + }), + ); +}; diff --git a/src/config/parseConfig.ts b/src/config/parseConfig.ts index 074ed991a4..da6de5be2e 100644 --- a/src/config/parseConfig.ts +++ b/src/config/parseConfig.ts @@ -50,6 +50,9 @@ export const parseConfig = ( ...(env.REA_MCP_MAX_RESPONSE_BYTES === undefined ? {} : { mcpMaxResponseBytes: env.REA_MCP_MAX_RESPONSE_BYTES }), + ...(env.REA_MCP_INPUT_SCHEMA_PROFILE === undefined + ? {} + : { mcpInputSchemaProfile: env.REA_MCP_INPUT_SCHEMA_PROFILE }), analysisProvider: env.REA_ANALYSIS_PROVIDER, ...(env.REA_IDA_MCP_CONFIG === undefined ? {} diff --git a/src/config/types.ts b/src/config/types.ts index 37696ff4cf..706db8e5cf 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -2,8 +2,11 @@ import type { LogLevel } from "../logger.js"; import type { ReferenceSourcePolicy } from "../domain/referenceSourcePolicy.js"; import type { AnalysisProviderSelector } from "../contracts/providerSelection.js"; +import type { McpInputSchemaProfile } from "./mcpInputSchemaProfile.js"; + export interface AppConfig { readonly mcpMaxResponseBytes?: number; + readonly mcpInputSchemaProfile?: McpInputSchemaProfile; readonly analysisProvider: AnalysisProviderSelector; readonly idaMcpConfigPath?: string; readonly ghidraInstallDir: string | undefined; diff --git a/src/contracts/compactInputSchema.test.ts b/src/contracts/compactInputSchema.test.ts new file mode 100644 index 0000000000..3fcfc42652 --- /dev/null +++ b/src/contracts/compactInputSchema.test.ts @@ -0,0 +1,314 @@ +import { Ajv2020 } from "ajv/dist/2020.js"; +import { describe, expect, it } from "vitest"; + +import { compactAdvertisedInputSchema } from "./compactInputSchema.js"; + +const serializedBytes = (value: unknown): number => + Buffer.byteLength(JSON.stringify(value), "utf8"); + +const repeatedObservation = { + type: "object", + properties: { + captured_url: { type: "string", minLength: 1 }, + captured_at: { type: "string", minLength: 1 }, + status_code: { type: "integer", minimum: 100, maximum: 599 }, + headers: { + type: "object", + additionalProperties: { type: "string" }, + minProperties: 1, + }, + body_digest: { type: "string", minLength: 64, maxLength: 64 }, + }, + required: ["captured_url", "captured_at", "status_code"], + additionalProperties: false, +} as const; + +const annotatedSchema = { + type: "object", + description: "Root group guidance stays.", + properties: { + query: { + type: "string", + description: "Field guidance leaves.", + examples: ["seed"], + default: "seed", + title: "Query", + minLength: 1, + }, + limits: { + type: "object", + description: "Nested guidance leaves.", + properties: { + max_results: { + type: "integer", + description: "Leaves.", + minimum: 1, + maximum: 500, + default: 50, + }, + }, + required: ["max_results"], + additionalProperties: false, + }, + }, + required: ["query"], + dependentRequired: { limits: ["query"] }, + additionalProperties: false, +} as const; + +const oversizedPairSchema = (): Record => { + const deepStructure = { + type: "object", + description: "Nested guidance that leaves.", + properties: Object.fromEntries( + Array.from({ length: 40 }, (_, index) => [ + `field_${index}`, + { type: "string", minLength: 1, description: `Field ${index}` }, + ]), + ), + required: Array.from({ length: 40 }, (_, index) => `field_${index}`), + additionalProperties: false, + }; + return { + type: "object", + description: "Provide a complete capture pair.", + properties: { + before: structuredClone(deepStructure), + after: structuredClone(deepStructure), + }, + required: ["before", "after"], + additionalProperties: false, + }; +}; + +describe("compact advertised input schema", () => { + it("drops nested annotation prose while keeping validation keywords", () => { + const { schema, presentation } = compactAdvertisedInputSchema( + structuredClone(annotatedSchema), + 13 * 1024, + ); + expect(presentation).toBe("inline"); + expect(schema.description).toBe("Root group guidance stays."); + const query = (schema.properties as Record) + .query as Record; + expect(query).toEqual({ type: "string", minLength: 1 }); + const limits = (schema.properties as Record) + .limits as Record; + expect(limits.description).toBeUndefined(); + expect(limits.required).toEqual(["max_results"]); + expect(schema.dependentRequired).toEqual({ limits: ["query"] }); + expect(schema.required).toEqual(["query"]); + }); + + it("keeps annotation-shaped keys inside literal const and enum data", () => { + const literalMode = { + description: "saved data", + default: { keep: "me" }, + }; + const { schema } = compactAdvertisedInputSchema( + { + type: "object", + properties: { + mode: { enum: [literalMode] }, + pinned: { const: { description: "literal" } }, + }, + }, + 13 * 1024, + ); + const properties = schema.properties as Record; + expect((properties.mode as Record).enum).toEqual([ + literalMode, + ]); + expect((properties.pinned as Record).const).toEqual({ + description: "literal", + }); + }); + + it("inlines local references without changing validation or literal data", () => { + const original = { + type: "object", + $defs: { "observation/~": structuredClone(repeatedObservation) }, + properties: { + before: { $ref: "#/$defs/observation~1~0" }, + after: { $ref: "#/$defs/observation~1~0" }, + literal: { const: { $ref: "saved data", $defs: { keep: true } } }, + }, + required: ["before", "after"], + additionalProperties: false, + }; + const { schema, presentation } = compactAdvertisedInputSchema( + original, + 13312, + ); + expect(presentation).toBe("inline"); + expect(schema.$defs).toBeUndefined(); + const ajv = new Ajv2020({ strict: false }); + const canonical = ajv.compile(original); + const compact = ajv.compile(schema); + const observation = { + captured_url: "https://example.com", + captured_at: "now", + status_code: 200, + }; + for (const input of [ + { before: observation, after: observation }, + { before: observation, after: { ...observation, status_code: 99 } }, + { + before: observation, + after: observation, + literal: original.properties.literal.const, + }, + ]) + expect(compact(input)).toBe(canonical(input)); + }); + + it("keeps unconstrained JSON values explicit for client type inference", () => { + const { schema } = compactAdvertisedInputSchema( + { + type: "object", + properties: { normalized_result: {}, literal: { const: {} } }, + }, + 13312, + ); + const properties = schema.properties as Record; + expect(properties.normalized_result).toEqual({ + type: ["null", "boolean", "object", "array", "number", "string"], + }); + expect(properties.literal).toEqual({ const: {} }); + const validate = new Ajv2020({ strict: false }).compile(schema); + for (const normalized_result of [null, true, {}, [], 42, "result"]) + expect(validate({ normalized_result })).toBe(true); + }); + + it("preserves independent validation constraints on reference siblings", () => { + const original = { + type: "object", + $defs: { text: { type: "string", minLength: 3 } }, + properties: { + query: { $ref: "#/$defs/text", minLength: 1, maxLength: 5 }, + }, + }; + const { schema } = compactAdvertisedInputSchema(original, 13312); + const validate = new Ajv2020({ strict: false }).compile(schema); + expect(validate({ query: "a" })).toBe(false); + expect(validate({ query: "abc" })).toBe(true); + expect(validate({ query: "abcdef" })).toBe(false); + }); +}); + +describe("compact input schema budget and reduction", () => { + it("budgets repeated-reference expansion before materializing it", () => { + const definitions: Record = { + leaf: structuredClone(repeatedObservation), + }; + for (let index = 0; index < 30; index++) { + const $ref = `#/$defs/${index === 0 ? "leaf" : `level${index - 1}`}`; + definitions[`level${index}`] = { + type: "object", + properties: { left: { $ref }, right: { $ref } }, + }; + } + const { schema, presentation } = compactAdvertisedInputSchema( + { + type: "object", + $defs: definitions, + properties: { tree: { $ref: "#/$defs/level29" } }, + }, + 1024, + ); + expect(presentation).toBe("reduced"); + expect(serializedBytes(schema)).toBeLessThanOrEqual(1024); + expect(schema.properties).toEqual({ tree: { type: "object" } }); + }); + + it("retains referenced object, array and nullable types in reduced inputs", () => { + const original = { + type: "object", + $defs: { + pair: oversizedPairSchema(), + observations: { + type: "array", + items: structuredClone(repeatedObservation), + }, + nullable: { anyOf: [{ type: "string" }, { type: "null" }] }, + }, + properties: { + capture: { $ref: "#/$defs/pair", description: "Complete capture." }, + observations: { $ref: "#/$defs/observations" }, + label: { $ref: "#/$defs/nullable" }, + }, + }; + const { schema, presentation } = compactAdvertisedInputSchema( + original, + 1024, + ); + expect(presentation).toBe("reduced"); + expect(schema.properties).toEqual({ + capture: { type: "object", description: "Complete capture." }, + observations: { type: "array" }, + label: { type: ["string", "null"] }, + }); + const validate = new Ajv2020({ strict: false }).compile(schema); + expect(validate({ capture: {}, observations: [], label: null })).toBe(true); + expect(validate({ capture: {}, observations: [], label: "capture" })).toBe( + true, + ); + expect(validate({ capture: "{}", observations: [], label: null })).toBe( + false, + ); + }); + + it("reduces structurally oversized schemas to the shallow property presentation", () => { + const compacted = compactAdvertisedInputSchema(oversizedPairSchema(), 1024); + expect(compacted.presentation).toBe("reduced"); + expect(serializedBytes(compacted.schema)).toBeLessThanOrEqual(1024); + expect(compacted.schema.type).toBe("object"); + expect(compacted.schema.description).toContain( + "Provide a complete capture pair.", + ); + expect(compacted.schema.description).toContain( + "compact input schema profile", + ); + const properties = compacted.schema.properties as Record; + expect(properties.before).toEqual({ + type: "object", + description: "Nested guidance that leaves.", + }); + expect(properties.after).toEqual(properties.before); + expect(compacted.schema.required).toEqual(["before", "after"]); + }); + + it("produces deterministic output for equal input", () => { + const schema = { + type: "object", + properties: { + before: structuredClone(repeatedObservation), + after: structuredClone(repeatedObservation), + }, + }; + expect(compactAdvertisedInputSchema(schema, 13 * 1024)).toEqual( + compactAdvertisedInputSchema(structuredClone(schema), 13 * 1024), + ); + }); + + it("keeps inline presentations under the measured provider budget", () => { + const { schema, presentation } = compactAdvertisedInputSchema( + { + type: "object", + description: "Group guidance.", + properties: { + before: structuredClone(repeatedObservation), + after: structuredClone(repeatedObservation), + normalization: { + type: "object", + description: "Rules.", + properties: { rules: { type: "array", items: { type: "string" } } }, + }, + }, + }, + 13312, + ); + expect(presentation).toBe("inline"); + expect(serializedBytes(schema)).toBeLessThanOrEqual(13312); + }); +}); diff --git a/src/contracts/compactInputSchema.ts b/src/contracts/compactInputSchema.ts new file mode 100644 index 0000000000..321ea83235 --- /dev/null +++ b/src/contracts/compactInputSchema.ts @@ -0,0 +1,311 @@ +import { + schemaArrays, + schemaChildren, + schemaMaps, +} from "./inputSchemaPresentation.js"; + +// Kimi expands schema-local references before sending tools to Moonshot. +// Compact advertisements must fit after expansion, independently of client +// reference handling. Canonical server-side validation remains unchanged. + +/** Annotation keys that carry guidance but no validation semantics. */ +const annotationKeys = new Set(["description", "examples", "default", "title"]); + +/** Keys whose values are literal data, never nested schemas to rewrite. */ +const dataKeys = new Set(["const", "enum", "examples", "default"]); +const definitionKeys = new Set(["$defs", "definitions"]); +// An empty schema accepts every JSON value. Kimi otherwise infers string. +const jsonValueTypes = [ + "null", + "boolean", + "object", + "array", + "number", + "string", +]; + +export type AdvertisedInputSchemaPresentation = "inline" | "reduced"; + +export interface CompactedInputSchema { + readonly schema: Record; + readonly presentation: AdvertisedInputSchemaPresentation; +} + +const isSchemaObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const serializedBytes = (value: unknown): number => + Buffer.byteLength(JSON.stringify(value), "utf8"); + +/** Rewrite every schema-valued position; literal data stays opaque. */ +const rebuildSchemaPositions = ( + node: unknown, + rebuild: (child: unknown) => unknown, +): unknown => { + if (Array.isArray(node)) return node.map((child) => rebuild(child)); + if (!isSchemaObject(node)) return node; + const out: Record = {}; + for (const [key, value] of Object.entries(node)) { + if (dataKeys.has(key)) { + out[key] = value; + continue; + } + if (schemaMaps.has(key) && isSchemaObject(value)) { + out[key] = Object.fromEntries( + Object.entries(value).map(([name, child]) => [name, rebuild(child)]), + ); + continue; + } + if (schemaArrays.has(key) && Array.isArray(value)) { + out[key] = value.map((child) => rebuild(child)); + continue; + } + if (schemaChildren.has(key)) { + out[key] = Array.isArray(value) + ? value.map((child) => rebuild(child)) + : rebuild(value); + continue; + } + out[key] = value; + } + return out; +}; + +/** + * Drop annotation prose from every subschema while preserving all validation + * keywords. The root keeps its own description because the root-union + * projection encodes accepted input groups there; the root's echoed literal + * examples leave with the rest of the annotation prose. + */ +const stripAnnotations = ( + schema: Record, +): Record => { + const rebuilt = rebuildSchemaPositions( + schema, + stripChildAnnotations, + ) as Record; + const out: Record = {}; + for (const [key, value] of Object.entries(rebuilt)) { + if (annotationKeys.has(key) && key !== "description") continue; + out[key] = value; + } + return out; +}; + +const stripChildAnnotations = (child: unknown): unknown => { + const rebuilt = rebuildSchemaPositions(child, stripChildAnnotations); + if (!isSchemaObject(rebuilt)) return rebuilt; + const kept: Record = {}; + for (const [key, value] of Object.entries(rebuilt)) + if (!annotationKeys.has(key)) kept[key] = value; + return kept; +}; + +const resolveLocalReference = ( + root: Record, + reference: string, +): unknown => { + if (reference === "#") return root; + if (!reference.startsWith("#/")) return undefined; + let value: unknown = root; + for (const token of reference.slice(2).split("/")) { + const key = token.replaceAll("~1", "/").replaceAll("~0", "~"); + if (!isSchemaObject(value) || !Object.hasOwn(value, key)) return undefined; + value = value[key]; + } + return value; +}; + +/** Stop serialization at the budget before allocating an expanded schema. */ +const inlineWithinBudget = ( + root: Record, + budgetBytes: number, +): Record | undefined => { + let resolved = true; + function* serialize( + value: unknown, + position: "schema" | "map" | "data", + active: ReadonlySet, + ): Generator { + if (Array.isArray(value)) { + yield "["; + for (const [index, child] of value.entries()) { + if (index > 0) yield ","; + yield* serialize(child, position, active); + } + yield "]"; + } else if (isSchemaObject(value)) { + if (position === "schema" && Object.keys(value).length === 0) { + yield JSON.stringify({ type: jsonValueTypes }); + return; + } + if (position === "schema" && typeof value.$ref === "string") { + const target = resolveLocalReference(root, value.$ref); + if (target === undefined || active.has(value.$ref)) { + resolved = false; + return; + } + const { $ref, ...siblings } = value; + // Reference siblings are independent constraints in draft 2020-12. + // Merging their keys into the target would overwrite validation rules. + yield* serialize( + Object.keys(siblings).length === 0 + ? target + : { allOf: [target, siblings] }, + "schema", + new Set([...active, $ref]), + ); + return; + } + yield "{"; + let first = true; + for (const [key, child] of Object.entries(value)) { + if (position === "schema" && definitionKeys.has(key)) continue; + if (!first) yield ","; + first = false; + yield JSON.stringify(key) + ":"; + const childPosition = + position === "map" + ? "schema" + : position === "schema" && schemaMaps.has(key) + ? "map" + : position === "schema" && + (schemaArrays.has(key) || schemaChildren.has(key)) + ? "schema" + : "data"; + yield* serialize(child, childPosition, active); + } + yield "}"; + } else { + yield JSON.stringify(value); + } + } + const chunks: string[] = []; + let bytes = 0; + for (const chunk of serialize(root, "schema", new Set())) { + bytes += Buffer.byteLength(chunk, "utf8"); + if (bytes > budgetBytes || !resolved) return undefined; + chunks.push(chunk); + } + return resolved + ? (JSON.parse(chunks.join("")) as Record) + : undefined; +}; + +/** Preserve every possible type, including referenced and nullable unions. */ +const detectedTypes = ( + schema: unknown, + root: Record, + active: ReadonlySet = new Set(), +): readonly string[] | undefined => { + if (!isSchemaObject(schema)) return undefined; + if (typeof schema.$ref === "string") { + if (active.has(schema.$ref)) return undefined; + return detectedTypes( + resolveLocalReference(root, schema.$ref), + root, + new Set([...active, schema.$ref]), + ); + } + if (typeof schema.type === "string") return [schema.type]; + if ( + Array.isArray(schema.type) && + schema.type.every((type) => typeof type === "string") + ) + return schema.type; + const alternatives = Array.isArray(schema.anyOf) + ? schema.anyOf + : Array.isArray(schema.oneOf) + ? schema.oneOf + : []; + if (alternatives.length === 0) return undefined; + const types = alternatives.map((branch) => + detectedTypes(branch, root, active), + ); + if (types.some((type) => type === undefined)) return undefined; + return [...new Set(types.flatMap((type) => type ?? []))]; +}; + +const REDUCED_ANNOTATION = + "Advertised in reduced form by the compact input schema profile; the server still validates complete canonical inputs."; + +/** + * Present only the root property shape with per-property guidance. Used for + * schemas whose validation structure alone exceeds the compact budget; the + * canonical schema still rejects malformed calls server-side. + */ +const reduceToShallowProperties = ( + schema: Record, +): Record => { + const properties = isSchemaObject(schema.properties) ? schema.properties : {}; + const description = [ + typeof schema.description === "string" ? schema.description : undefined, + REDUCED_ANNOTATION, + ] + .filter((text) => text !== undefined) + .join(" "); + const $schema = + typeof schema.$schema === "string" ? { $schema: schema.$schema } : {}; + const shallow = Object.fromEntries( + Object.entries(properties).map(([name, value]) => { + const types = detectedTypes(value, schema); + const type = types?.length === 1 ? types[0] : types; + const description = + isSchemaObject(value) && typeof value.description === "string" + ? { description: value.description } + : {}; + return [ + name, + { ...(type === undefined ? {} : { type }), ...description }, + ]; + }), + ); + return { + ...$schema, + type: "object", + description, + properties: shallow, + ...(Array.isArray(schema.required) + ? { required: [...schema.required] } + : {}), + ...(typeof schema.minProperties === "number" + ? { minProperties: schema.minProperties } + : {}), + ...(isSchemaObject(schema.dependentRequired) + ? { dependentRequired: { ...schema.dependentRequired } } + : {}), + ...(schema.additionalProperties === false + ? { additionalProperties: false } + : {}), + }; +}; + +/** + * Render an advertised input schema within a per-schema byte budget for + * providers that validate the literal serialized schema size. Validation + * keywords and the canonical contract are preserved; only + * annotation prose and, for structurally oversized schemas, nested + * validation detail leave the advertised form. + */ +export const compactAdvertisedInputSchema = ( + schema: Record, + budgetBytes: number, +): CompactedInputSchema => { + const inline = inlineWithinBudget(stripAnnotations(schema), budgetBytes); + if (inline !== undefined) return { schema: inline, presentation: "inline" }; + const reduced = reduceToShallowProperties(schema); + if (serializedBytes(reduced) <= budgetBytes) + return { schema: reduced, presentation: "reduced" }; + // A future contract with an extreme root-property count still advertises + // an honest, budget-fitting shape instead of an oversized one. + return { + schema: reduceToShallowProperties({ + type: "object", + description: + (typeof schema.description === "string" + ? `${schema.description} ` + : "") + REDUCED_ANNOTATION, + }), + presentation: "reduced", + }; +}; diff --git a/src/contracts/inputSchemaPresentation.ts b/src/contracts/inputSchemaPresentation.ts index 3b83dc4a75..9663dd8329 100644 --- a/src/contracts/inputSchemaPresentation.ts +++ b/src/contracts/inputSchemaPresentation.ts @@ -1,6 +1,6 @@ import { isDeepStrictEqual } from "node:util"; -const schemaMaps = new Set([ +export const schemaMaps = new Set([ "$defs", "definitions", "properties", @@ -8,8 +8,8 @@ const schemaMaps = new Set([ "dependentSchemas", "dependencies", ]); -const schemaArrays = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]); -const schemaChildren = new Set([ +export const schemaArrays = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]); +export const schemaChildren = new Set([ "items", "additionalItems", "contains", diff --git a/src/contracts/toolSchemaMetadata.ts b/src/contracts/toolSchemaMetadata.ts index e2a6e50af5..34089a929f 100644 --- a/src/contracts/toolSchemaMetadata.ts +++ b/src/contracts/toolSchemaMetadata.ts @@ -95,6 +95,46 @@ export const toolOutputSchemaWithMetadata = ( }, ); +/** + * Apply a wire-presentation transform to one schema's advertised input + * projection. The wrapper preserves the schema's own parser and standard + * metadata, so both Zod registrations and plain standard schemas keep their + * canonical validation while only the advertised JSON changes. + */ +export const transformAdvertisedInputJsonSchema = ( + schema: Schema, + transform: (projected: Record) => Record, +): Schema => { + const standard = Reflect.get(schema as object, "~standard"); + if (!isSchemaRecord(standard)) return schema; + const jsonSchema = Reflect.get(standard, "jsonSchema"); + if (!isSchemaRecord(jsonSchema)) return schema; + const project = Reflect.get(jsonSchema, "input"); + if (typeof project !== "function") return schema; + const byTarget = new Map>(); + const transformedInput = (options: unknown): unknown => { + const target = Reflect.get(options as object, "target"); + if (typeof target === "string") { + const cached = byTarget.get(target); + if (cached !== undefined) return cached; + } + const projected = (project as (options: unknown) => unknown)(options); + const transformed = isSchemaRecord(projected) + ? transform(projected) + : projected; + if (typeof target === "string" && isSchemaRecord(transformed)) + byTarget.set(target, transformed); + return transformed; + }; + return { + ...(schema as object), + "~standard": { + ...standard, + jsonSchema: { ...jsonSchema, input: transformedInput }, + }, + } as Schema; +}; + const hasRelativeSchemaReference = ( schema: Record, ): boolean => { @@ -164,6 +204,9 @@ const memoizeByTarget = ( }; }; +const isSchemaRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + const fallbackPropertyDescription = (property: string): string | undefined => { const explicit = Object.hasOwn(PROPERTY_DESCRIPTIONS, property) ? PROPERTY_DESCRIPTIONS[property] diff --git a/src/server/EvidenceMcpServer.ts b/src/server/EvidenceMcpServer.ts index 706ad05772..d5f5e0598f 100644 --- a/src/server/EvidenceMcpServer.ts +++ b/src/server/EvidenceMcpServer.ts @@ -5,14 +5,22 @@ import { isJSONRPCNotification, isJSONRPCResultResponse, type Implementation, + type Icon, type JSONRPCMessage, type McpServerOptions, + type RegisteredTool, type RequestId, + type ScopeChallengeHandler, + type StandardSchemaWithJSON, + type ToolAnnotations, + type ToolCallback, type Transport, type TransportSendOptions, } from "@modelcontextprotocol/server"; import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js"; +import { compactAdvertisedInputSchema } from "../contracts/compactInputSchema.js"; +import { transformAdvertisedInputJsonSchema } from "../contracts/toolSchemaMetadata.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; @@ -21,6 +29,24 @@ import { jsonObjectSchema } from "../domain/jsonValue.js"; import type { ToolResultDelivery } from "./toolResult.js"; import { encodeToolResult } from "./toolResultEncoding.js"; +/** Compact advertised input schemas within this per-schema byte budget. */ +export interface CompactInputSchemaPresentation { + readonly budgetBytes: number; +} + +/** A Zod schema whose standard interface exposes a JSON Schema projection. */ +const hasAdvertisedInputJsonSchema = (value: unknown): boolean => { + if (typeof value !== "object" || value === null) return false; + const standard = Reflect.get(value, "~standard"); + if (typeof standard !== "object" || standard === null) return false; + const jsonSchema = Reflect.get(standard, "jsonSchema"); + return ( + typeof jsonSchema === "object" && + jsonSchema !== null && + Reflect.get(jsonSchema, "input") !== undefined + ); +}; + /** Bind oversized MCP error recovery to this server's Evidence ledger. */ export class EvidenceMcpServer extends McpServer { constructor( @@ -30,10 +56,60 @@ export class EvidenceMcpServer extends McpServer { | EvidenceWriter["recordEvidence"] | undefined, readonly delivery: ToolResultDelivery, + private readonly compactPresentation: + | CompactInputSchemaPresentation + | undefined = undefined, ) { super(info, options); } + /** + * Compact the advertised input schema of every registered tool when the + * compact profile is active. The registered canonical schema, its parser, + * and output schemas are untouched; only the advertised JSON Schema + * projection is rendered within the provider's per-schema size budget. + * The signatures mirror the SDK's registrations so call sites keep their + * inferred handler argument types. + */ + override registerTool< + OutputArgs extends StandardSchemaWithJSON, + InputArgs extends StandardSchemaWithJSON | undefined = undefined, + >( + name: string, + config: { + title?: string; + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + icons?: Icon[]; + scopeChallenge?: ScopeChallengeHandler; + _meta?: Record; + }, + callback: ToolCallback, + ): RegisteredTool { + const budget = this.compactPresentation?.budgetBytes; + const inputSchema = + budget !== undefined && + config !== undefined && + hasAdvertisedInputJsonSchema(config.inputSchema) + ? transformAdvertisedInputJsonSchema( + config.inputSchema as Parameters< + typeof transformAdvertisedInputJsonSchema + >[0], + (projected) => + compactAdvertisedInputSchema(projected, budget).schema, + ) + : undefined; + return super.registerTool( + name, + (inputSchema === undefined + ? config + : { ...config, inputSchema }) as never, + callback as never, + ); + } + /** Preserve the SDK transport lifecycle while retaining oversized errors. */ override connect(transport: Transport): Promise { return super.connect( diff --git a/src/server/createServer.ts b/src/server/createServer.ts index b578e26c50..f5d3884bf2 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -15,6 +15,11 @@ import { type ToolResultDelivery, } from "./toolResult.js"; import { parseMcpResponseBudget } from "../config/mcpResponseBudget.js"; +import { + COMPACT_INPUT_SCHEMA_BUDGET_BYTES, + parseMcpInputSchemaProfile, +} from "../config/mcpInputSchemaProfile.js"; +import type { CompactInputSchemaPresentation } from "./EvidenceMcpServer.js"; import { isAbsolute } from "node:path"; import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js"; @@ -148,6 +153,7 @@ const installSessionToolAvailability = ( const createMcpServer = ( session: BinarySessionPort | undefined, delivery: ToolResultDelivery, + compactPresentation: CompactInputSchemaPresentation | undefined, ): EvidenceMcpServer => new EvidenceMcpServer( { @@ -165,6 +171,7 @@ const createMcpServer = ( ? undefined : (evidence) => session.recordEvidence(evidence), delivery, + compactPresentation, ); /** @@ -183,7 +190,11 @@ export const createServer = ( const selectedOptions = { ...options, environment, delivery }; const startedAt = new Date().toISOString(); const logger = options.logger ?? silentLogger; - const server = createMcpServer(session, delivery); + const server = createMcpServer( + session, + delivery, + selectCompactInputSchemaPresentation(environment), + ); const android = options.androidAnalysis ?? createAndroidAnalysisProvider(environment); const availability = installSessionToolAvailability( @@ -256,6 +267,18 @@ const selectToolResultDelivery = ( return createToolResultDelivery(configured.value); }; +const selectCompactInputSchemaPresentation = ( + environment: Readonly, +): CompactInputSchemaPresentation | undefined => { + const configured = parseMcpInputSchemaProfile( + environment.REA_MCP_INPUT_SCHEMA_PROFILE, + ); + if (!configured.ok) throw configured.error; + return configured.value === "compact" + ? { budgetBytes: COMPACT_INPUT_SCHEMA_BUDGET_BYTES } + : undefined; +}; + const registerConfiguredAnalysisTools = ( { server, diff --git a/tests/boundary/mcp/compactInputSchemaMcp.test.ts b/tests/boundary/mcp/compactInputSchemaMcp.test.ts new file mode 100644 index 0000000000..536b7afeb5 --- /dev/null +++ b/tests/boundary/mcp/compactInputSchemaMcp.test.ts @@ -0,0 +1,281 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { describe, expect, it } from "vitest"; + +import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js"; +import { compactAdvertisedInputSchema } from "../../../src/contracts/compactInputSchema.js"; +import { + COMPACT_INPUT_SCHEMA_BUDGET_BYTES, + parseMcpInputSchemaProfile, +} from "../../../src/config/mcpInputSchemaProfile.js"; +import { processScenarioSchema } from "../../../src/domain/process/processScenario.js"; +import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js"; +import { ToolResultDelivery } from "../../../src/server/toolResult.js"; +import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js"; + +interface AdvertisedTool { + readonly name: string; + readonly inputSchema: Record; + readonly outputSchema?: Record | undefined; + readonly description?: string | undefined; +} + +const serializedBytes = (value: unknown): number => + Buffer.byteLength(JSON.stringify(value), "utf8"); + +const listToolsThroughTransport = async ( + server: EvidenceMcpServer, +): Promise => { + const client = new Client({ name: "compact-profile", version: "0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + return (await client.listTools()).tools; + } finally { + await Promise.allSettled([client.close(), server.close()]); + } +}; + +const registerCatalog = (server: EvidenceMcpServer): void => { + for (const contract of TOOL_CONTRACTS) + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async () => ({ content: [] }), + ); +}; + +const compactServer = (): EvidenceMcpServer => + new EvidenceMcpServer( + { name: "compact-profile", version: "0" }, + { capabilities: {} }, + undefined, + new ToolResultDelivery(STDIO_DEFAULT_MAX_BUFFER_SIZE), + { budgetBytes: COMPACT_INPUT_SCHEMA_BUDGET_BYTES }, + ); + +const fullServer = (): EvidenceMcpServer => + new EvidenceMcpServer( + { name: "full-profile", version: "0" }, + { capabilities: {} }, + undefined, + new ToolResultDelivery(STDIO_DEFAULT_MAX_BUFFER_SIZE), + ); + +describe("MCP compact input schema profile", () => { + it("advertises every tool input schema within the provider budget", async () => { + const server = compactServer(); + registerCatalog(server); + const tools = await listToolsThroughTransport(server); + expect(tools.map(({ name }) => name).sort()).toEqual( + TOOL_CONTRACTS.map(({ name }) => name).sort(), + ); + const oversized = tools.filter( + ({ inputSchema }) => + serializedBytes(inputSchema) > COMPACT_INPUT_SCHEMA_BUDGET_BYTES, + ); + expect(oversized.map(({ name }) => name)).toEqual([]); + }); + + it("keeps output schemas and tool descriptions identical to the default profile", async () => { + const compact = compactServer(); + const full = fullServer(); + registerCatalog(compact); + registerCatalog(full); + const [compactTools, fullTools] = await Promise.all([ + listToolsThroughTransport(compact), + listToolsThroughTransport(full), + ]); + const fullByName = new Map(fullTools.map((tool) => [tool.name, tool])); + expect(compactTools.length).toBe(fullTools.length); + for (const tool of compactTools) { + const reference = fullByName.get(tool.name); + if (reference === undefined) throw new Error(`Missing ${tool.name}`); + expect(tool.outputSchema, tool.name).toEqual(reference.outputSchema); + expect(tool.description, tool.name).toBe(reference.description); + } + }); + + it("keeps compact schemas valid and accepting canonical examples", async () => { + const server = compactServer(); + registerCatalog(server); + const tools = await listToolsThroughTransport(server); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const contractsByName = new Map( + TOOL_CONTRACTS.map((contract) => [contract.name, contract]), + ); + for (const tool of tools) { + expect( + ajv.validateSchema(tool.inputSchema), + `${tool.name}: ${ajv.errorsText(ajv.errors)}`, + ).toBe(true); + const contract = contractsByName.get(tool.name); + const validate = ajv.compile(tool.inputSchema); + for (const example of contract?.examples ?? []) + expect(validate(example.input), `${tool.name}: ${example.title}`).toBe( + true, + ); + } + }); + + it("reduces at most the structurally oversized tools", async () => { + const full = fullServer(); + registerCatalog(full); + const tools = await listToolsThroughTransport(full); + const reduced: string[] = []; + for (const tool of tools) { + const { presentation } = compactAdvertisedInputSchema( + tool.inputSchema, + COMPACT_INPUT_SCHEMA_BUDGET_BYTES, + ); + if (presentation === "reduced") reduced.push(tool.name); + } + expect(reduced.sort()).toEqual([ + "build_reconstruction_obligation_ledger", + "capture_browser_scenario", + "compare_application_versions", + "compare_web_captures", + "evaluate_reconstruction_coverage", + "project_managed_application_graph", + ]); + expect(tools.length).toBe(TOOL_CONTRACTS.length); + }); + + it("retains parameter types and avoids client-side reference expansion", async () => { + const server = compactServer(); + registerCatalog(server); + const tools = await listToolsThroughTransport(server); + for (const tool of tools) { + // Kimi Code 2.1.1 expands references and defaults description-only + // properties to string. Inline schemas with explicit shallow types + // avoid both conversions for the structurally oversized tools. + expect(JSON.stringify(tool.inputSchema), tool.name).not.toContain( + '"$ref":', + ); + } + const comparison = tools.find( + ({ name }) => name === "compare_managed_members", + ); + expect(comparison?.inputSchema.properties).toMatchObject({ + left: { + properties: { + normalized_result: { + type: ["null", "boolean", "object", "array", "number", "string"], + }, + }, + }, + }); + const ledger = tools.find( + ({ name }) => name === "build_reconstruction_obligation_ledger", + ); + expect(ledger?.inputSchema.properties).toMatchObject({ + evidence_bundle: { type: "object" }, + reviewed_obligations: { type: "array" }, + manifest: { type: "object" }, + }); + const coverage = tools.find( + ({ name }) => name === "evaluate_reconstruction_coverage", + ); + expect(coverage?.inputSchema.properties).toMatchObject({ + coverage: { type: "object" }, + boundary_id: { type: "string" }, + }); + }); + + it("parses the profile selection the server startup reads", () => { + expect(parseMcpInputSchemaProfile("compact")).toEqual({ + ok: true, + value: "compact", + }); + expect(parseMcpInputSchemaProfile("surprise")).not.toHaveProperty("value"); + }); +}); + +describe("MCP compact input schema canonical enforcement", () => { + it("still rejects canonical-invalid arguments the compact advertisement accepts", async () => { + const server = compactServer(); + const contract = TOOL_CONTRACTS.find( + ({ name }) => name === "capture_process_scenario", + ); + if (contract === undefined) + throw new Error("Process capture contract was not registered"); + let handlerCalled = false; + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async () => { + handlerCalled = true; + return { + content: [{ type: "text" as const, text: "handler ran" }], + isError: true, + }; + }, + ); + const client = new Client({ name: "compact-validation", version: "0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const advertised = (await client.listTools()).tools.find( + ({ name }) => name === contract.name, + ); + if (advertised === undefined) + throw new Error("Process capture tool was not advertised"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const validate = ajv.compile(advertised.inputSchema); + const valid = { executable: "node", environment: { APP_MODE: "test" } }; + const reserved = { + executable: "node", + environment: { REA_PROCESS_RUN_ID: "caller-value" }, + }; + expect(validate(valid)).toBe(true); + expect(processScenarioSchema.safeParse(valid).success).toBe(true); + expect(processScenarioSchema.safeParse(reserved).success).toBe(false); + const result = await client.callTool({ + name: contract.name, + arguments: reserved, + }); + expect(result.isError).toBe(true); + expect(handlerCalled).toBe(false); + } finally { + await Promise.allSettled([client.close(), server.close()]); + } + }); + + it("compacts plain standard schemas that are not Zod instances", async () => { + // Mirrors registerWebNetworkCaptureTool: a plain standard schema reusing + // the advertised projection while owning its own permissive validator. + const server = compactServer(); + const contract = TOOL_CONTRACTS.find( + ({ name }) => name === "inspect_web_network_capture", + ); + if (contract === undefined) + throw new Error("Network capture contract was not registered"); + const registration = toolRegistrationOptions(contract); + const inputSchema = { + "~standard": { + ...registration.inputSchema["~standard"], + vendor: "rea-application", + validate: (value: unknown) => ({ value }), + }, + }; + server.registerTool( + contract.name, + { ...registration, inputSchema }, + async () => ({ content: [] }), + ); + const tools = await listToolsThroughTransport(server); + const advertised = tools.find(({ name }) => name === contract.name); + if (advertised === undefined) + throw new Error("Network capture tool was not advertised"); + expect(serializedBytes(advertised.inputSchema)).toBeLessThanOrEqual( + COMPACT_INPUT_SCHEMA_BUDGET_BYTES, + ); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + expect(ajv.validateSchema(advertised.inputSchema)).toBe(true); + }); +}); diff --git a/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts b/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts index ba90eafdfe..72b6165621 100644 --- a/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts +++ b/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts @@ -43,12 +43,14 @@ const exerciseDeliveryFailure = async ( recordEvidence, delivery, ); - server.registerTool("large_failure", { inputSchema: {} }, async () => - oversizedFailure(), + server.registerTool( + "large_failure", + { inputSchema: z.object({}) }, + async () => oversizedFailure(), ); server.registerTool( "healthy", - { inputSchema: {} }, + { inputSchema: z.object({}) }, async (): Promise => ({ content: [{ type: "text", text: "still connected" }], }), @@ -161,7 +163,7 @@ it("delivers an error whose text fits although structured and text copies would }, delivery, ); - server.registerTool("mid_failure", { inputSchema: {} }, async () => + server.registerTool("mid_failure", { inputSchema: z.object({}) }, async () => failureWithName(nameBytes), ); const client = new Client({ name: "mid-sized-error-client", version: "1" }); diff --git a/tests/conformance/contracts/mcpInputSchemaProfile.test.ts b/tests/conformance/contracts/mcpInputSchemaProfile.test.ts new file mode 100644 index 0000000000..ababc135e8 --- /dev/null +++ b/tests/conformance/contracts/mcpInputSchemaProfile.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { parseMcpInputSchemaProfile } from "../../../src/config/mcpInputSchemaProfile.js"; + +describe("MCP input schema profile configuration", () => { + it("accepts the full and compact presentations", () => { + expect(parseMcpInputSchemaProfile(undefined)).toEqual({ + ok: true, + value: undefined, + }); + expect(parseMcpInputSchemaProfile("full")).toEqual({ + ok: true, + value: "full", + }); + expect(parseMcpInputSchemaProfile("compact")).toEqual({ + ok: true, + value: "compact", + }); + }); + + it.each(["", "FULL", "compact ", "minimal", "0"])( + "rejects unknown profiles: %j", + (value) => { + const parsed = parseMcpInputSchemaProfile(value); + if (parsed.ok) throw new Error("Expected invalid profile"); + expect(parsed.error.message).toContain("REA_MCP_INPUT_SCHEMA_PROFILE"); + }, + ); +}); diff --git a/tests/process-global/mcpDeliveryEnvironment.test.ts b/tests/process-global/mcpDeliveryEnvironment.test.ts index 6d8751d5e5..51e371b45a 100644 --- a/tests/process-global/mcpDeliveryEnvironment.test.ts +++ b/tests/process-global/mcpDeliveryEnvironment.test.ts @@ -1,6 +1,7 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server"; import { afterEach, expect, it, vi } from "vitest"; +import { z } from "zod"; import { createAnalysisExecution } from "../../src/application/AnalysisProvider.js"; import { AnalysisInputError } from "../../src/domain/analysisErrorCore.js"; @@ -73,16 +74,19 @@ it("keeps independent server budgets for successful results and oversized errors Math.ceil((smallBudget + 65536) / 17), ); for (const server of [small, large]) - server.registerTool("selected_failure", { inputSchema: {} }, async () => - server.delivery.toErrorToolResult( - new AnalysisInputError("fixture", undefined, [ - { - path: ["procedure"], - reason: "invalid_value", - message: diagnostic, - }, - ]), - ), + server.registerTool( + "selected_failure", + { inputSchema: z.object({}) }, + async () => + server.delivery.toErrorToolResult( + new AnalysisInputError("fixture", undefined, [ + { + path: ["procedure"], + reason: "invalid_value", + message: diagnostic, + }, + ]), + ), ); const [smallClient, largeClient] = await Promise.all([