diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 7ba2b247fb..40e44baf8b 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -1208,16 +1208,34 @@ 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. +Claude Desktop drops a local connector tool, with a generic "problem with that +connector" warning, when its input schema serialized as compact JSON exceeds +64 KiB ([#4081](https://github.com/morluto/rea/issues/4081)). Every advertised +input schema therefore fits 65,536 UTF-8 bytes, including its examples. A +self-contained schema that would exceed the limit is fitted in two steps, and +its root description says it is summarized. First, a property whose schema +repeats an earlier sibling's, such as `after` beside `before`, is advertised as +its types and description plus "Same schema as `before`." Second, schemas below +the deepest instance level that still fits are advertised as their types, size +constraints and descriptions, unless that summary would be longer than the +schema itself. Combinator and conditional branches count as the +level of the instance they describe. Both steps only widen the advertisement: +canonical inputs and examples stay accepted, required names and nested +constraints below the cut are not advertised, and the canonical contract still +validates every call. Schemas within the limit are advertised unchanged. The +compact profile starts from the complete projection, not from the fitted one. + `search_bytes` advertises its pattern and mask as descriptive strings. Its shared canonical validator scans byte pairs, whitespace, wildcards and aligned masks without a repeating regular expression or temporary byte arrays. The server rejects malformed or unconstrained patterns before provider dispatch; the advertised strings alone do not express those rules. -Boolean and empty-string literal constraints in input schemas are relaxed only -in this advertisement. Boolean `const` becomes `type: boolean`; empty-string -`const` or an enum containing an empty string is widened without dropping the -accepted empty value. Defaults and descriptions remain intact. +Boolean, numeric and empty-string literal constraints in input schemas are +relaxed only in this advertisement. Boolean `const` becomes `type: boolean`; +numeric `const` keeps its numeric type; empty-string `const`, an enum containing +an empty string, and an enum containing a number are widened without dropping +an accepted value. Defaults and descriptions remain intact. An affected `not` is omitted, conditional predicates and their branches are omitted together, pure boolean-literal `oneOf` branches collapse to a boolean, and other affected `oneOf` branches are presented as `anyOf` to retain branch diff --git a/scripts/verify-copilot-client.mjs b/scripts/verify-copilot-client.mjs index 4225f87561..ea61833630 100644 --- a/scripts/verify-copilot-client.mjs +++ b/scripts/verify-copilot-client.mjs @@ -10,7 +10,7 @@ import { toolContract, } from "../dist/contracts/toolContracts.js"; import { advertisedInputJsonSchema } from "../dist/contracts/toolSchemaMetadata.js"; -import { presentAdvertisedInputSchema } from "../dist/contracts/compactInputSchema.js"; +import { presentAdvertisedInputProfile } from "../dist/contracts/inputSchemaPresentation.js"; import { compactPresentationForMcpInputSchemaProfile } from "../dist/config/mcpInputSchemaProfile.js"; import { createVerifierCommands } from "./lib/verify-commands.mjs"; import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs"; @@ -186,7 +186,7 @@ await withVerifierCleanup(async () => { ); assert.deepEqual( tool.parameters, - presentAdvertisedInputSchema( + presentAdvertisedInputProfile( advertisedInputJsonSchema( toolContract(tool.name.slice(prefix.length)), ), diff --git a/src/catalogIdentity.ts b/src/catalogIdentity.ts index afd53aa764..27ef212e1c 100644 --- a/src/catalogIdentity.ts +++ b/src/catalogIdentity.ts @@ -12,10 +12,8 @@ import { advertisedInputJsonSchema, toolOutputSchemaWithMetadata, } from "./contracts/toolSchemaMetadata.js"; -import { - presentAdvertisedInputSchema, - type CompactInputSchemaPresentation, -} from "./contracts/compactInputSchema.js"; +import type { CompactInputSchemaPresentation } from "./contracts/compactInputSchema.js"; +import { presentAdvertisedInputProfile } from "./contracts/inputSchemaPresentation.js"; import type { McpOutputSchemaProfile } from "./config/mcpOutputSchemaProfile.js"; import { COMPACT_INPUT_SCHEMA_BUDGET_BYTES, @@ -62,7 +60,7 @@ const presentToolCatalog = ( ? catalog : catalog.map((tool) => ({ ...tool, - input_schema: presentAdvertisedInputSchema( + input_schema: presentAdvertisedInputProfile( tool.input_schema, presentation, ), diff --git a/src/contracts/inputSchemaFitInvariants.test.ts b/src/contracts/inputSchemaFitInvariants.test.ts new file mode 100644 index 0000000000..398fcd2412 --- /dev/null +++ b/src/contracts/inputSchemaFitInvariants.test.ts @@ -0,0 +1,264 @@ +import { Ajv } from "ajv"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect, it } from "vitest"; + +import { advertisedInputExamples } from "./advertisedInputExamples.js"; +import { + ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES, + fitAdvertisedInputSchema, + presentInputJsonSchema, + summarizedInputSchemaCandidate, + summarizedSchema, +} from "./inputSchemaPresentation.js"; +import { + schemaArrays, + schemaChildren, + schemaLiteralValues, + schemaMaps, +} from "./jsonSchemaKeywords.js"; +import { TOOL_CONTRACTS } from "./toolContracts.js"; + +type Schema = Record; + +const bytes = (value: unknown): number => + Buffer.byteLength(JSON.stringify(value), "utf8"); + +const isObject = (value: unknown): value is Schema => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** The self-contained projection before fitting, with its advertised examples. */ +const completeProjection = ( + contract: (typeof TOOL_CONTRACTS)[number], +): Schema => { + const inline = contract.inputSchema["~standard"].jsonSchema.input({ + target: "draft-2020-12", + libraryOptions: { reused: "inline" }, + }) as Schema; + const examples = advertisedInputExamples(contract); + return { + ...presentInputJsonSchema(inline, () => undefined), + ...(examples.length === 0 ? {} : { examples }), + }; +}; + +/** Every subschema in a schema position, ignoring literal data. */ +const subschemas = function* (schema: unknown): Generator { + if (!isObject(schema)) return; + yield schema; + for (const [key, child] of Object.entries(schema)) { + if (schemaLiteralValues.has(key)) continue; + if (schemaMaps.has(key) && isObject(child)) + for (const nested of Object.values(child)) yield* subschemas(nested); + else if (schemaArrays.has(key) && Array.isArray(child)) + for (const nested of child) yield* subschemas(nested); + else if (schemaChildren.has(key)) + for (const nested of Array.isArray(child) ? child : [child]) + yield* subschemas(nested); + } +}; + +/** Every JSON value nested in a value, including the value itself. */ +const values = function* (value: unknown): Generator { + yield value; + if (Array.isArray(value)) for (const item of value) yield* values(item); + else if (isObject(value)) + for (const item of Object.values(value)) yield* values(item); +}; + +const GENERIC_PROBES: readonly unknown[] = [ + null, + true, + false, + 0, + 1, + -1, + 1.5, + "", + "a", + [], + [null], + [1], + ["a"], + ["a", 1], + [[]], + [{}], + {}, + { kind: "a" }, +]; + +/** Assert a summary accepts every probe the complete schema accepts. */ +const expectSummaryWidens = ( + schemas: Iterable, + probes: readonly unknown[], + dialect: "draft-2020-12" | "draft-07" = "draft-2020-12", +): number => { + const options = { strict: false, validateFormats: false }; + const ajv = dialect === "draft-07" ? new Ajv(options) : new Ajv2020(options); + const seen = new Set(); + const failures: string[] = []; + for (const schema of schemas) { + const key = JSON.stringify(schema); + if (seen.has(key)) continue; + seen.add(key); + const summary = summarizedSchema(schema); + const complete = ajv.compile(schema); + const summarized = ajv.compile(summary); + for (const probe of probes) + if (complete(probe) && !summarized(probe)) + failures.push( + `${key.slice(0, 200)} -> ${JSON.stringify(summary)} rejects ${JSON.stringify(probe).slice(0, 200)}`, + ); + ajv.removeSchema(); + } + expect(failures).toEqual([]); + return seen.size; +}; + +const SYNTHETIC_SUMMARIES: readonly Schema[] = [ + // 2020-12 tuple positions with a differently typed rest. + { + type: "array", + prefixItems: [{ type: "string" }], + items: { type: "number" }, + }, + { type: "array", prefixItems: [{ type: "string" }] }, + { type: "array", prefixItems: [{ type: "string" }], items: false }, + { type: "array", items: { type: "string" }, contains: { const: "a" } }, + { + anyOf: [ + { type: "array", prefixItems: [{ type: "boolean" }] }, + { type: "null" }, + ], + }, + { type: ["array", "null"], items: { type: "string" }, minItems: 1 }, + { type: "object", properties: { a: { type: "string" } }, required: ["a"] }, + { not: { type: "string" } }, + { allOf: [{ type: "string" }, { minLength: 1 }] }, + { oneOf: [{ const: 1 }, { enum: ["a", null] }] }, + { + if: { type: "string" }, + then: { minLength: 1 }, + else: { type: "number" }, + }, +]; + +const DRAFT_07_TUPLES: readonly Schema[] = [ + { + type: "array", + items: [{ type: "string" }], + additionalItems: { type: "number" }, + }, + { type: "array", items: [{ type: "string" }] }, + { type: "array", items: [{ type: "string" }], additionalItems: false }, +]; + +it("summarizes synthetic schemas without rejecting values they accept", () => { + expect( + summarizedSchema({ + type: "array", + prefixItems: [{ type: "string" }], + items: { type: "number" }, + }), + ).toEqual({ type: "array", items: { type: ["string", "number"] } }); + expectSummaryWidens(SYNTHETIC_SUMMARIES, [ + ...GENERIC_PROBES, + [true], + ["a", "b", 2], + [1, "a"], + [false, null], + ]); + expectSummaryWidens( + DRAFT_07_TUPLES, + [...GENERIC_PROBES, ["a", 2], ["a", "b"]], + "draft-07", + ); +}); + +it("summarizes every catalog input subschema without rejecting values it accepts", () => { + const projections = TOOL_CONTRACTS.map(completeProjection); + // Canonical example values reach deep nested subschemas with valid data. + const probes = [ + ...GENERIC_PROBES, + ...new Map( + projections + .flatMap((schema) => + Array.isArray(schema.examples) ? schema.examples : [], + ) + .flatMap((example) => [...values(example)]) + .map((value) => [JSON.stringify(value), value] as const), + ).values(), + ]; + const checked = expectSummaryWidens( + projections.flatMap((schema) => + [...subschemas(schema)].filter((node) => node !== schema), + ), + probes, + ); + expect(checked).toBeGreaterThan(1000); +}, 120_000); + +/** Assert no candidate keeping more levels than the fitted one also fits. */ +const expectDeepestFittingCandidate = (schema: Schema): void => { + const fitted = fitAdvertisedInputSchema(schema); + expect(bytes(fitted)).toBeLessThanOrEqual( + ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES, + ); + const described = { ...schema, description: fitted.description }; + const candidates = Array.from({ length: 40 }, (_, index) => + summarizedInputSchemaCandidate(described, index + 1), + ); + const deepest = candidates.findLastIndex( + (candidate) => bytes(candidate) <= ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES, + ); + expect(deepest).toBeGreaterThanOrEqual(0); + expect(fitted).toEqual(candidates[deepest]); +}; + +it("keeps a deeper fitting candidate when a shallower summary is longer", () => { + // A nullable array summary needs anyOf, so it is longer than the schema. + const nullable = { type: ["array", "null"], items: { type: "string" } }; + const schema = { + type: "object", + properties: { + ...Object.fromEntries( + Array.from({ length: 1_000 }, (_, index) => [ + `list_${index}`, + nullable, + ]), + ), + // Retaining its third level would overflow; its second level fits. + large: { + type: "object", + properties: { + inner: { + type: "object", + properties: { + text: { type: "string", description: "x".repeat(30_000) }, + }, + }, + }, + }, + }, + }; + // Summarizing every list at the first level would overflow by itself. + expect(1_000 * bytes(summarizedSchema(nullable))).toBeGreaterThan( + ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES, + ); + expect(bytes(schema)).toBeGreaterThan(ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES); + const fitted = fitAdvertisedInputSchema(schema); + expect(bytes(fitted)).toBeLessThanOrEqual( + ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES, + ); + const properties = fitted.properties as Record; + expect(properties.list_0).toEqual(nullable); + expect(properties.large.properties.inner).toEqual({ type: "object" }); + expectDeepestFittingCandidate(schema); +}); + +it("fits oversized catalog input schemas at their deepest fitting level", () => { + const oversized = TOOL_CONTRACTS.map(completeProjection).filter( + (schema) => bytes(schema) > ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES, + ); + expect(oversized.length).toBeGreaterThan(0); + for (const schema of oversized) expectDeepestFittingCandidate(schema); +}, 120_000); diff --git a/src/contracts/inputSchemaPresentation.test.ts b/src/contracts/inputSchemaPresentation.test.ts new file mode 100644 index 0000000000..bae749b206 --- /dev/null +++ b/src/contracts/inputSchemaPresentation.test.ts @@ -0,0 +1,371 @@ +import { Ajv2020 } from "ajv/dist/2020.js"; +import { describe, expect, it } from "vitest"; + +import { presentAdvertisedInputSchema } from "./compactInputSchema.js"; +import { + ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES, + fitAdvertisedInputSchema, + presentAdvertisedInputProfile, + presentInputJsonSchema, +} from "./inputSchemaPresentation.js"; + +const bytes = (value: unknown): number => + Buffer.byteLength(JSON.stringify(value), "utf8"); + +const ajv = () => new Ajv2020({ strict: false, validateFormats: false }); + +/** A strict object `levels` deep with `width` distinct padded string leaves. */ +const record = ( + levels: number, + width: number, + pad = 80, + path = "f", +): Record => ({ + type: "object", + properties: Object.fromEntries( + Array.from({ length: width }, (_, index) => [ + `field_${index}`, + levels <= 1 + ? { + type: "string", + minLength: 1, + description: `Leaf ${path}.${index} ${"x".repeat(pad)}`, + } + : { + ...record(levels - 1, width, pad, `${path}.${index}`), + description: `Level ${index}`, + }, + ]), + ), + required: ["field_0"], + additionalProperties: false, +}); + +const instance = (levels: number, width: number): Record => + Object.fromEntries( + Array.from({ length: width }, (_, index) => [ + `field_${index}`, + levels <= 1 ? "value" : instance(levels - 1, width), + ]), + ); + +const expectWidened = ( + original: Record, + fitted: Record, + inputs: readonly unknown[], +) => { + const validateOriginal = ajv().compile(original); + const validateFitted = ajv().compile(fitted); + for (const input of inputs) + if (validateOriginal(input)) + expect(validateFitted(input), JSON.stringify(validateFitted.errors)).toBe( + true, + ); +}; + +it("returns schemas that already fit unchanged", () => { + const schema = { + type: "object", + properties: { capture: record(3, 4) }, + examples: [{ capture: instance(3, 4) }], + }; + expect(bytes(schema)).toBeLessThan(ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES); + expect(fitAdvertisedInputSchema(schema)).toBe(schema); +}); + +it("points repeated sibling properties at the first instead of repeating them", () => { + const capture = record(3, 7, 40); + const schema = { + type: "object", + description: "Compare two captures.", + properties: { + before: { ...capture, description: "Earlier capture." }, + after: { ...capture, description: "Later capture." }, + unrelated: { type: "string" }, + }, + required: ["before", "after"], + additionalProperties: false, + examples: [{ before: instance(3, 7), after: instance(3, 7) }], + }; + expect(bytes(schema)).toBeGreaterThan(ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES); + expect(bytes(capture)).toBeLessThan(ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES); + + const fitted = fitAdvertisedInputSchema(schema); + expect(bytes(fitted)).toBeLessThanOrEqual( + ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES, + ); + expect(fitted).toMatchObject({ + description: expect.stringMatching( + /^Compare two captures\. .*summarized form.*complete canonical inputs\.$/u, + ), + properties: { + before: schema.properties.before, + after: { + type: "object", + description: "Later capture. Same schema as `before`.", + }, + unrelated: { type: "string" }, + }, + required: ["before", "after"], + additionalProperties: false, + examples: schema.examples, + }); + expect(Object.keys(fitted.properties as object)).toEqual([ + "before", + "after", + "unrelated", + ]); + expect(ajv().validateSchema(fitted)).toBe(true); + expectWidened(schema, fitted, [ + ...schema.examples, + { before: instance(3, 7), after: { field_0: { field_0: {} } } }, + ]); +}); + +it("keeps the deepest instance level that fits and summarizes below it", () => { + const schema = { + type: "object", + properties: { capture: record(4, 6) }, + additionalProperties: false, + }; + expect(bytes(schema)).toBeGreaterThan(ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES); + const fitted = fitAdvertisedInputSchema(schema); + expect(bytes(fitted)).toBeLessThanOrEqual( + ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES, + ); + const capture = (fitted.properties as Record).capture; + // Third-level objects keep their properties; fourth-level ones are summarized. + const third = capture.properties.field_1.properties.field_2; + expect(third).toMatchObject({ + required: ["field_0"], + additionalProperties: false, + }); + expect(third.properties.field_3).toEqual({ + type: "object", + description: "Level 3", + }); + expectWidened(schema, fitted, [ + { capture: instance(4, 6) }, + { capture: { field_0: { field_0: { field_0: { field_0: "" } } } } }, + ]); +}); + +it("counts combinator and conditional branches at their instance level", () => { + const branch = (kind: string) => ({ + type: "object", + properties: { + kind: { type: "string", enum: [kind] }, + body: record(3, 9), + }, + required: ["kind", "body"], + additionalProperties: false, + }); + const schema = { + type: "object", + properties: { + value: { anyOf: [branch("a"), branch("b"), { type: "null" }] }, + }, + }; + expect(bytes(schema)).toBeGreaterThan(ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES); + const fitted = fitAdvertisedInputSchema(schema); + const value = (fitted.properties as Record).value; + // The union keeps every branch and its own properties at level two. + expect(value.anyOf).toHaveLength(3); + expect(value.anyOf[0].properties.kind).toEqual({ + type: "string", + enum: ["a"], + }); + expect(value.anyOf[2]).toEqual({ type: "null" }); +}); + +it("relaxes nonmonotone positions instead of narrowing them", () => { + const deep = record(3, 7); + const schema = { + type: "object", + properties: { + exactly_one: { + oneOf: [ + { ...deep, required: ["field_0"] }, + { ...deep, required: ["field_1"] }, + ], + }, + excluded: { type: "object", not: deep }, + conditional: { + type: "object", + if: deep, + then: { required: ["field_0"] }, + else: { required: ["field_1"] }, + }, + bounded: { type: "array", contains: deep, maxContains: 1 }, + }, + }; + expect(bytes(schema)).toBeGreaterThan(ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES); + const fitted = fitAdvertisedInputSchema(schema); + expect(bytes(fitted)).toBeLessThanOrEqual( + ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES, + ); + const properties = fitted.properties as Record; + expect(properties.exactly_one.oneOf).toBeUndefined(); + expect(properties.exactly_one.anyOf).toHaveLength(2); + expect(properties.excluded).not.toHaveProperty("not"); + expect(properties.conditional).not.toHaveProperty("if"); + expect(properties.conditional).not.toHaveProperty("then"); + expect(properties.bounded).not.toHaveProperty("maxContains"); + const valid = instance(3, 7); + expectWidened(schema, fitted, [ + { exactly_one: { field_0: valid.field_0 } }, + { exactly_one: { field_1: valid.field_1 } }, + { excluded: { other: true } }, + { conditional: { field_1: valid.field_1 } }, + { conditional: valid }, + { bounded: [valid, { field_0: 1 }] }, + ]); +}); + +it("advertises items only beside the single array type", () => { + const rows = { + type: "array", + items: { type: "object", properties: { id: { type: "string" } } }, + minItems: 1, + description: "Rows.", + }; + const schema = { + type: "object", + properties: { + // Its large leaf one level deeper limits the retained depth to two. + large: { + type: "object", + properties: { + inner: { + type: "object", + properties: { + text: { type: "string", description: "x".repeat(70_000) }, + }, + }, + }, + }, + arrays: { + type: "object", + properties: { + rows, + optional_rows: { anyOf: [rows, { type: "null" }] }, + cube: { type: "array", items: { type: "array", items: rows } }, + }, + }, + }, + }; + const fitted = fitAdvertisedInputSchema(schema); + expect(bytes(fitted)).toBeLessThanOrEqual( + ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES, + ); + expect((fitted.properties as Record).arrays).toEqual({ + type: "object", + properties: { + rows: { + type: "array", + items: { type: "object" }, + minItems: 1, + description: "Rows.", + }, + optional_rows: { + anyOf: [{ type: "array", items: { type: "object" } }, { type: "null" }], + }, + cube: { type: "array", items: {} }, + }, + }); + expectWidened(schema, fitted, [ + { + arrays: { + rows: [{ id: "a" }], + optional_rows: null, + cube: [[[{ id: "c" }]]], + }, + }, + { arrays: { optional_rows: [{ id: "b" }] } }, + ]); +}); + +it("falls back to the compact projection for schemas with local references", () => { + const schema = { + type: "object", + $defs: { capture: record(3, 12) }, + properties: { + before: { $ref: "#/$defs/capture" }, + after: { $ref: "#/$defs/capture" }, + }, + }; + const fitted = fitAdvertisedInputSchema(schema); + expect(bytes(fitted)).toBeLessThanOrEqual( + ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES, + ); + expect(JSON.stringify(fitted)).not.toContain("$ref"); + expect(ajv().validateSchema(fitted)).toBe(true); +}); + +it("presents the compact profile from the complete projection", () => { + const capture = record(3, 7); + const complete = { + type: "object", + properties: { + before: { ...capture, description: "Earlier capture." }, + after: { ...capture, description: "Later capture." }, + }, + }; + const fitted = fitAdvertisedInputSchema(complete); + expect(fitted).not.toEqual(complete); + const budget = { budgetBytes: 13 * 1024 }; + expect(presentAdvertisedInputProfile(fitted, budget)).toEqual( + presentAdvertisedInputSchema(complete, budget), + ); + expect(presentAdvertisedInputProfile(fitted, undefined)).toBe(fitted); +}); + +describe("advertised numeric literals", () => { + it("relaxes numeric const and enum members without narrowing", () => { + const schema = { + type: "object", + properties: { + version: { type: "integer", const: 2 }, + ratio: { const: 0.5 }, + level: { enum: [1, 2, 3] }, + mixed: { enum: ["auto", 7] }, + not_two: { type: "integer", not: { const: 2 } }, + }, + }; + const presented = presentInputJsonSchema(schema, () => undefined); + expect(presented.properties).toEqual({ + version: { type: "integer" }, + ratio: { type: "number" }, + level: { type: "integer" }, + mixed: { type: ["string", "integer"] }, + not_two: { type: "integer" }, + }); + expectWidened(schema, presented, [ + { version: 2, ratio: 0.5, level: 3, mixed: "auto", not_two: 3 }, + { mixed: 7 }, + ]); + }); + + it("drops unevaluated keywords when relaxing an applicator they depend on", () => { + const schema = { + type: "object", + properties: { + options: { + type: "object", + properties: { mode: { type: "integer" } }, + if: { properties: { mode: { const: 2 } }, required: ["mode"] }, + then: { properties: { extra: { type: "string" } } }, + unevaluatedProperties: false, + }, + }, + }; + const presented = presentInputJsonSchema(schema, () => undefined); + expect(presented.properties).toEqual({ + options: { type: "object", properties: { mode: { type: "integer" } } }, + }); + expectWidened(schema, presented, [ + { options: { mode: 2, extra: "kept" } }, + { options: { mode: 1 } }, + ]); + }); +}); diff --git a/src/contracts/inputSchemaPresentation.ts b/src/contracts/inputSchemaPresentation.ts index dc40eefe02..186756bedb 100644 --- a/src/contracts/inputSchemaPresentation.ts +++ b/src/contracts/inputSchemaPresentation.ts @@ -9,6 +9,11 @@ import { schemaChildren, schemaMaps, } from "./jsonSchemaKeywords.js"; +import { + compactAdvertisedInputSchema, + presentAdvertisedInputSchema, + type CompactInputSchemaPresentation, +} from "./compactInputSchema.js"; const isObject = (value: unknown): value is Readonly> => typeof value === "object" && value !== null && !Array.isArray(value); @@ -155,6 +160,223 @@ type SchemaNormalization = { readonly changed: boolean; }; +const serializedBytes = (value: unknown): number => + Buffer.byteLength(JSON.stringify(value), "utf8"); + +/** Instance nesting levels below the current node that keep their structure. */ +type SchemaSummary = { readonly levels: number }; + +// Keywords whose subschemas describe a nested instance. Combinators, +// conditionals and dependent schemas describe the same instance as their parent. +const instanceChildKeys = new Set([ + "properties", + "patternProperties", + "additionalProperties", + "unevaluatedProperties", + "propertyNames", + "prefixItems", + "items", + "additionalItems", + "contains", + "unevaluatedItems", +]); + +const hasNestedSchema = (schema: Readonly>): boolean => + Object.entries(schema).some(([key, child]) => + schemaMaps.has(key) + ? isObject(child) && Object.values(child).some(isObject) + : schemaArrays.has(key) + ? Array.isArray(child) && child.length > 0 + : schemaChildren.has(key) && isObject(child), + ); + +/** Count nested instance levels a schema describes, ignoring literal data. */ +const schemaLevels = (schema: unknown): number => { + if (!isObject(schema)) return 0; + let deepest = 0; + for (const [key, child] of Object.entries(schema)) { + const children = schemaMaps.has(key) + ? isObject(child) + ? Object.values(child) + : [] + : schemaArrays.has(key) + ? Array.isArray(child) + ? child + : [] + : schemaChildren.has(key) + ? [child] + : []; + const step = instanceChildKeys.has(key) ? 1 : 0; + for (const nested of children) + if (isObject(nested)) + deepest = Math.max(deepest, step + schemaLevels(nested)); + } + return deepest; +}; + +const literalType = (value: unknown): string => { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (typeof value === "number") + return Number.isInteger(value) ? "integer" : "number"; + return typeof value; +}; + +const typeKeyword = (types: readonly string[]): string | string[] => + types.length === 1 ? types[0]! : [...types]; + +/** Every JSON type a schema can accept, or undefined when that is not explicit. */ +const schemaTypes = (schema: unknown): readonly string[] | undefined => { + if (!isObject(schema)) return undefined; + if (typeof schema.type === "string") return [schema.type]; + if ( + Array.isArray(schema.type) && + schema.type.length > 0 && + schema.type.every((type) => typeof type === "string") + ) + return schema.type; + const literals = Object.hasOwn(schema, "const") + ? [schema.const] + : Array.isArray(schema.enum) && schema.enum.length > 0 + ? schema.enum + : undefined; + if (literals !== undefined) return [...new Set(literals.map(literalType))]; + const branches = Array.isArray(schema.anyOf) + ? schema.anyOf + : Array.isArray(schema.oneOf) + ? schema.oneOf + : []; + const types = branches.map(schemaTypes); + return types.length > 0 && types.every((type) => type !== undefined) + ? [...new Set(types.flat())] + : undefined; +}; + +/** Item types of an explicitly typed array, including tuple positions. */ +const typedArrayItemTypes = ( + schema: Readonly>, +): readonly string[] | undefined => { + // Tuple positions come from `prefixItems` (2020-12) or an `items` array + // (draft-07); later elements from `items` or `additionalItems`. + const positions = [ + ...(Array.isArray(schema.prefixItems) ? schema.prefixItems : []), + ...(Array.isArray(schema.items) ? schema.items : []), + ]; + const rest = Array.isArray(schema.items) + ? schema.additionalItems + : schema.items; + // An absent or `true` rest schema admits any later element. + if (rest !== false && !isObject(rest)) return undefined; + const types = [...positions, ...(rest === false ? [] : [rest])].map( + schemaTypes, + ); + return types.every((type) => type !== undefined) + ? [...new Set(types.flat())] + : undefined; +}; + +/** Every type an array accepted by the schema can contain, when explicit. */ +const arrayItemTypes = (schema: unknown): readonly string[] | undefined => { + if (!isObject(schema)) return undefined; + const types = schemaTypes(schema); + if (types !== undefined && !types.includes("array")) return []; + if (schema.type !== undefined) return typedArrayItemTypes(schema); + const branches = Array.isArray(schema.anyOf) + ? schema.anyOf + : Array.isArray(schema.oneOf) + ? schema.oneOf + : []; + const itemTypes = branches.map(arrayItemTypes); + return itemTypes.length > 0 && itemTypes.every((type) => type !== undefined) + ? [...new Set(itemTypes.flat())] + : undefined; +}; + +// Size constraints on the summarized instance itself. Required names stay +// with the properties they name: Gemini rejects required undeclared properties. +const summarizedConstraintKeys = [ + "minProperties", + "maxProperties", + "minItems", + "maxItems", +] as const; + +/** + * Replace a nested schema with its accepted types, own size constraints and + * guidance. The summary accepts every value the complete + * schema accepts, so it only widens the advertisement; `items` appears only + * beside the single `array` type. + */ +export const summarizedSchema = ( + schema: Readonly>, +): Record => { + const retained = { + ...Object.fromEntries( + summarizedConstraintKeys.flatMap((key) => + Object.hasOwn(schema, key) ? [[key, schema[key]]] : [], + ), + ), + ...(typeof schema.description === "string" + ? { description: schema.description } + : {}), + }; + const types = schemaTypes(schema); + if (types === undefined) return retained; + const others = types.filter((type) => type !== "array"); + if (others.length === types.length) + return { type: typeKeyword(types), ...retained }; + const itemTypes = arrayItemTypes(schema); + const array = { + type: "array", + items: + itemTypes === undefined || + itemTypes.length === 0 || + itemTypes.includes("array") + ? {} + : { type: typeKeyword(itemTypes) }, + }; + return others.length === 0 + ? { ...array, ...retained } + : { anyOf: [array, { type: typeKeyword(others) }], ...retained }; +}; + +const withoutDescription = ({ + description: _description, + ...schema +}: Readonly>): Readonly> => + schema; + +/** + * Summarize a property whose nested schema repeats an earlier sibling's, such + * as `after` beside `before`, as a pointer to that sibling. Records the first + * occurrence of each nested schema in `complete`. + */ +const sharedSiblingSchema = ( + name: string, + child: unknown, + complete: [string, Readonly>][], +): Record | undefined => { + if (!isObject(child) || !hasNestedSchema(child)) return undefined; + const shape = withoutDescription(child); + const sibling = complete.find(([, known]) => + isDeepStrictEqual(withoutDescription(known), shape), + )?.[0]; + if (sibling === undefined) { + complete.push([name, child]); + return undefined; + } + const shared = { + ...summarizedSchema(child), + description: [ + typeof child.description === "string" ? child.description : undefined, + `Same schema as \`${sibling}\`.`, + ] + .filter((text) => text !== undefined) + .join(" "), + }; + return serializedBytes(shared) < serializedBytes(child) ? shared : undefined; +}; + const isBooleanLiteralSchema = (schema: unknown): boolean => isObject(schema) && (typeof schema.const === "boolean" || @@ -182,36 +404,122 @@ const preserveSharedDefault = ( schema.default = defaults[0]; }; -const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => { +/** + * Relax literal constraints that Gemini declarations reject, in place, without + * rejecting a value the literal accepted. Returns whether anything changed. + */ +const relaxUnsupportedLiterals = (node: Record): boolean => { + let relaxed = false; + if (typeof node.const === "boolean") { + delete node.const; + if (node.type === undefined) node.type = "boolean"; + relaxed = true; + } + if (node.const === "") { + delete node.const; + if (node.type === undefined) node.type = "string"; + relaxed = true; + } + if ( + Array.isArray(node.enum) && + node.enum.length > 0 && + node.enum.every((item) => typeof item === "boolean") + ) { + delete node.enum; + if (node.type === undefined) node.type = "boolean"; + relaxed = true; + } + if (Array.isArray(node.enum) && node.enum.includes("")) { + // Removing only the empty member would reject a canonical-valid input. + // Gemini transports turn const into enum and refuse empty enum members. + const values = node.enum; + delete node.enum; + if ( + node.type === undefined && + values.every((item) => typeof item === "string") + ) + node.type = "string"; + relaxed = true; + } + if (typeof node.const === "number") { + // Gemini accepts only string enum members, and transports turn const into + // enum. The declared or literal numeric type still guides callers. + const literal = node.const; + delete node.const; + if (node.type === undefined) node.type = literalType(literal); + relaxed = true; + } + if ( + Array.isArray(node.enum) && + node.enum.some((item) => typeof item === "number") + ) { + const values = node.enum; + delete node.enum; + if (node.type === undefined) + node.type = typeKeyword([...new Set(values.map(literalType))]); + relaxed = true; + } + return relaxed; +}; + +const normalizeAdvertisedSchema = ( + value: unknown, + summary?: SchemaSummary, +): SchemaNormalization => { if (!isObject(value)) return { schema: value, changed: false }; + if (summary !== undefined && summary.levels <= 0 && hasNestedSchema(value)) { + // A summary can be longer than the schema it replaces, such as a nullable + // array presented as anyOf. Keep the complete schema then. + const summarized = summarizedSchema(value); + return serializedBytes(summarized) < serializedBytes(value) + ? { schema: summarized, changed: true } + : normalizeAdvertisedSchema(value); + } + const nested = (key: string): SchemaSummary | undefined => + summary !== undefined && instanceChildKeys.has(key) + ? { levels: summary.levels - 1 } + : summary; const node: Record = { ...value }; let changed = false; - const normalizeMap = (map: Readonly>) => - Object.fromEntries( + const normalizeMap = ( + key: string, + map: Readonly>, + ) => { + const complete: [string, Readonly>][] = []; + return Object.fromEntries( Object.entries(map).map(([name, child]) => { - const result = normalizeAdvertisedSchema(child); + const shared = + summary !== undefined && key === "properties" + ? sharedSiblingSchema(name, child, complete) + : undefined; + if (shared !== undefined) { + changed = true; + return [name, shared]; + } + const result = normalizeAdvertisedSchema(child, nested(key)); changed ||= result.changed; return [name, result.schema]; }), ); - const normalizeList = (list: readonly unknown[]) => + }; + const normalizeList = (key: string, list: readonly unknown[]) => list.map((child) => { - const result = normalizeAdvertisedSchema(child); + const result = normalizeAdvertisedSchema(child, nested(key)); changed ||= result.changed; return result; }); for (const [key, child] of Object.entries(value)) { if (schemaMaps.has(key) && isObject(child)) { - node[key] = normalizeMap(child); + node[key] = normalizeMap(key, child); } else if ( schemaArrays.has(key) && key !== "anyOf" && key !== "oneOf" && Array.isArray(child) ) { - node[key] = normalizeList(child).map((result) => result.schema); + node[key] = normalizeList(key, child).map((result) => result.schema); } else if ( schemaChildren.has(key) && key !== "not" && @@ -220,8 +528,8 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => { key !== "else" ) { const results = Array.isArray(child) - ? normalizeList(child) - : [normalizeAdvertisedSchema(child)]; + ? normalizeList(key, child) + : [normalizeAdvertisedSchema(child, nested(key))]; const nestedChanged = results.some((result) => result.changed); if ( key === "contains" && @@ -241,40 +549,10 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => { } } - if (typeof node.const === "boolean") { - delete node.const; - if (node.type === undefined) node.type = "boolean"; - changed = true; - } - if (node.const === "") { - delete node.const; - if (node.type === undefined) node.type = "string"; - changed = true; - } - if ( - Array.isArray(node.enum) && - node.enum.length > 0 && - node.enum.every((item) => typeof item === "boolean") - ) { - delete node.enum; - if (node.type === undefined) node.type = "boolean"; - changed = true; - } - if (Array.isArray(node.enum) && node.enum.includes("")) { - // Removing only the empty member would reject a canonical-valid input. - // Gemini transports turn const into enum and refuse empty enum members. - const values = node.enum; - delete node.enum; - if ( - node.type === undefined && - values.every((item) => typeof item === "string") - ) - node.type = "string"; - changed = true; - } + if (relaxUnsupportedLiterals(node)) changed = true; const negated = Object.hasOwn(value, "not") - ? normalizeAdvertisedSchema(value.not) + ? normalizeAdvertisedSchema(value.not, summary) : undefined; if (negated?.changed) { // Widening a predicate under `not` narrows the containing schema. @@ -285,13 +563,13 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => { } const conditional = Object.hasOwn(value, "if") - ? normalizeAdvertisedSchema(value.if) + ? normalizeAdvertisedSchema(value.if, summary) : undefined; const thenBranch = Object.hasOwn(value, "then") - ? normalizeAdvertisedSchema(value.then) + ? normalizeAdvertisedSchema(value.then, summary) : undefined; const elseBranch = Object.hasOwn(value, "else") - ? normalizeAdvertisedSchema(value.else) + ? normalizeAdvertisedSchema(value.else, summary) : undefined; if (conditional?.changed) { // Changing the predicate can route canonical-valid inputs into a rejecting branch. @@ -314,7 +592,7 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => { for (const combinator of ["anyOf", "oneOf"] as const) { const branches = value[combinator]; if (!Array.isArray(branches)) continue; - const normalized = normalizeList(branches); + const normalized = normalizeList(combinator, branches); const schemas = normalized.map((result) => result.schema); const branchesChanged = normalized.some((result) => result.changed); if (combinator === "oneOf" && branchesChanged) { @@ -353,6 +631,13 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => { changed ||= branchesChanged; } + if (changed) { + // A relaxed or summarized applicator can stop evaluating properties or + // items, which these keywords would then reject. + delete node.unevaluatedProperties; + delete node.unevaluatedItems; + } + return { schema: node, changed }; }; @@ -623,3 +908,95 @@ export const presentInputJsonSchema = ( description: description.join(" "), }).schema as Record; }; + +/** + * Claude Desktop drops a local connector tool whose input schema, serialized + * as compact JSON, exceeds 64 KiB (#4081). Other clients take the same + * catalog, so every advertised input schema fits this limit. + */ +export const ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES = 64 * 1024; + +const SUMMARIZED_GUIDANCE = + "Deeply nested and repeated inputs are advertised in summarized form to fit client schema-size limits; the server still validates complete canonical inputs."; + +// The compact profile starts from the complete self-contained projection, +// not from a fitted summary of it. +const completeProjections = new WeakMap>(); + +/** + * Return the input schema clients receive for the selected profile: the + * fitted full advertisement, or the compact presentation of its complete + * projection. + */ +export const presentAdvertisedInputProfile = ( + advertised: Record, + compactPresentation: CompactInputSchemaPresentation | undefined, +): Record => + compactPresentation === undefined + ? advertised + : presentAdvertisedInputSchema( + completeProjections.get(advertised) ?? advertised, + compactPresentation, + ); + +/** + * Fit an advertised input schema within the client size limit. A schema that + * already fits is returned unchanged. Otherwise identical sibling properties + * point at the first one, and nested schemas below the deepest level that + * still fits are summarized as their accepted types and guidance. Every + * rewrite widens the advertisement, so canonical inputs and examples remain + * accepted; the registered canonical schema still validates every call. + */ +export const fitAdvertisedInputSchema = ( + schema: Record, +): Record => { + if (serializedBytes(schema) <= ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES) + return schema; + // Summaries remove subschemas that a local reference could target. + const references = new JsonSchemaReferences(schema).references(); + if (references.next().done !== true) return fitByCompaction(schema); + const described = { + ...schema, + description: [ + typeof schema.description === "string" ? schema.description : undefined, + SUMMARIZED_GUIDANCE, + ] + .filter((text) => text !== undefined) + .join(" "), + }; + // Size need not grow with the retained depth: a deeper candidate can be + // smaller than a summary. Keep the deepest candidate that fits. + for (let levels = schemaLevels(schema); levels >= 1; levels -= 1) { + const candidate = summarizedInputSchemaCandidate(described, levels); + if (serializedBytes(candidate) <= ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES) { + completeProjections.set(candidate, schema); + return candidate; + } + } + return fitByCompaction(schema); +}; + +/** + * Advertise identical siblings as pointers and summarize schemas below the + * given instance level. Exposed so tests can compare fitting candidates. + */ +export const summarizedInputSchemaCandidate = ( + schema: Record, + levels: number, +): Record => + normalizeAdvertisedSchema(schema, { levels }).schema as Record< + string, + unknown + >; + +/** Fall back to the compact projection, which fits any budget. */ +const fitByCompaction = ( + schema: Record, +): Record => { + const { schema: fitted } = compactAdvertisedInputSchema( + schema, + ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES, + ); + completeProjections.set(fitted, schema); + return fitted; +}; diff --git a/src/contracts/toolSchemaMetadata.ts b/src/contracts/toolSchemaMetadata.ts index 5344f66c01..9fbc16b2be 100644 --- a/src/contracts/toolSchemaMetadata.ts +++ b/src/contracts/toolSchemaMetadata.ts @@ -6,7 +6,10 @@ import { z } from "zod"; import type { ToolContract } from "./toolContractTypes.js"; import { advertisedInputExamples } from "./advertisedInputExamples.js"; -import { presentInputJsonSchema } from "./inputSchemaPresentation.js"; +import { + fitAdvertisedInputSchema, + presentInputJsonSchema, +} from "./inputSchemaPresentation.js"; import { schemaArrays, schemaChildren, @@ -75,13 +78,13 @@ export const toolInputSchemaWithMetadata = ( libraryOptions: { ...options.libraryOptions, reused: "inline" }, }); const examples = advertisedInputExamples(contract); - return { - // Keep advertised parameters self-contained for clients that do not - // resolve local references. The registered canonical schema remains - // responsible for runtime validation. + // Keep advertised parameters self-contained for clients that do not + // resolve local references, within the per-tool client size limit. The + // registered canonical schema remains responsible for runtime validation. + return fitAdvertisedInputSchema({ ...presentInputJsonSchema(inline, fallbackPropertyDescription), ...(examples.length === 0 ? {} : { examples }), - }; + }); }, ); diff --git a/src/server/EvidenceMcpServerCore.ts b/src/server/EvidenceMcpServerCore.ts index 7edde2dd1d..5c1709ec3d 100644 --- a/src/server/EvidenceMcpServerCore.ts +++ b/src/server/EvidenceMcpServerCore.ts @@ -26,12 +26,10 @@ import { } from "@modelcontextprotocol/server"; import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js"; -import { - presentAdvertisedInputSchema, - type CompactInputSchemaPresentation, -} from "../contracts/compactInputSchema.js"; +import type { CompactInputSchemaPresentation } from "../contracts/compactInputSchema.js"; import type { createCatalogIdentity } from "../catalogIdentity.js"; import { transformAdvertisedInputJsonSchema } from "../contracts/toolSchemaMetadata.js"; +import { presentAdvertisedInputProfile } from "../contracts/inputSchemaPresentation.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; import { AnalysisInputError, @@ -259,7 +257,7 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { typeof transformAdvertisedInputJsonSchema >[0], (projected) => - presentAdvertisedInputSchema(projected, { budgetBytes: budget }), + presentAdvertisedInputProfile(projected, { budgetBytes: budget }), ) : undefined; const registeredConfig = diff --git a/tests/boundary/mcp/browserComparisonInput.test.ts b/tests/boundary/mcp/browserComparisonInput.test.ts index 8350c1dea1..b127451f0f 100644 --- a/tests/boundary/mcp/browserComparisonInput.test.ts +++ b/tests/boundary/mcp/browserComparisonInput.test.ts @@ -67,8 +67,8 @@ function invalidComparisonInputs( { before: passive.before }, { before: { inspection: {} }, after: passive.after }, { - before: passive.before, - after: { ...passive.after, webmcp: {} }, + before: { ...passive.before, webmcp: {} }, + after: passive.after, }, { before_scenario: {}, after_scenario: {} }, { before_scenario: scenario.before_scenario }, @@ -146,6 +146,10 @@ describe("browser comparison input boundary", () => { "before + after; before_scenario + after_scenario + [normalization]", ); const runtimeOnlyInvalid = [ + // The advertisement fits the 64 KiB client limit by presenting + // `after` as "Same schema as `before`" (#4081); the canonical + // parser still rejects a malformed `after` capture. + { before: passive.before, after: { ...passive.after, webmcp: {} } }, { ...passive, ...scenario }, { ...passive, normalization: { rules: [] } }, { diff --git a/tests/boundary/mcp/browserScenarioMcp.test.ts b/tests/boundary/mcp/browserScenarioMcp.test.ts index e8caca9da6..8ffec6819a 100644 --- a/tests/boundary/mcp/browserScenarioMcp.test.ts +++ b/tests/boundary/mcp/browserScenarioMcp.test.ts @@ -267,12 +267,17 @@ describe("browser scenario MCP tool", () => { validateComparison(comparisonInput), JSON.stringify(validateComparison.errors), ).toBe(true); - for (const malformed of [ - { before_scenario: captureResult }, - { before_scenario: captureResult, after: captureResult }, - { ...comparisonInput, before: captureResult, after: captureResult }, - ]) { - expect(validateFullComparison(malformed)).toBe(false); + for (const [malformed, advertisedRejects] of [ + [{ before_scenario: captureResult }, true], + // The fitted advertisement summarizes `after` as "Same schema as + // `before`"; only the canonical contract rejects a scenario in it. + [{ before_scenario: captureResult, after: captureResult }, false], + [ + { ...comparisonInput, before: captureResult, after: captureResult }, + true, + ], + ] as const) { + expect(validateFullComparison(malformed)).toBe(!advertisedRejects); const rejected = await client.callTool({ name: "compare_web_captures", arguments: malformed, diff --git a/tests/boundary/mcp/compactInputSchemaMcp.test.ts b/tests/boundary/mcp/compactInputSchemaMcp.test.ts index 6c5650469a..3c68c236f7 100644 --- a/tests/boundary/mcp/compactInputSchemaMcp.test.ts +++ b/tests/boundary/mcp/compactInputSchemaMcp.test.ts @@ -10,7 +10,10 @@ import { } from "../../../src/contracts/toolContracts.js"; import { PROMPT_CONTRACTS } from "../../../src/contracts/promptContracts.js"; import { advertisedInputJsonSchema } from "../../../src/contracts/toolSchemaMetadata.js"; -import { presentAdvertisedInputSchema } from "../../../src/contracts/compactInputSchema.js"; +import { + ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES, + presentAdvertisedInputProfile, +} from "../../../src/contracts/inputSchemaPresentation.js"; import { createCatalogDigests } from "../../../src/catalogIdentity.js"; import { schemaArrays, @@ -144,7 +147,7 @@ describe("MCP compact input schema profile", () => { ].entries()) { for (const contract of TOOL_CONTRACTS) { expect(toolsByName[index]?.get(contract.name)?.inputSchema).toEqual( - presentAdvertisedInputSchema( + presentAdvertisedInputProfile( advertisedInputJsonSchema(contract), presentation, ), @@ -208,6 +211,47 @@ it("advertises every tool input schema within the provider budget", async () => ).toEqual([]); }); +it("advertises every input schema within the client per-tool limit in both profiles", async () => { + // Claude Desktop drops a tool whose compact-JSON input schema exceeds 64 KiB. + for (const server of [fullServer(), compactServer()]) { + registerCatalog(server); + const tools = await listToolsThroughTransport(server); + expect(tools).toHaveLength(TOOL_CONTRACTS.length); + expect( + tools.flatMap(({ name, inputSchema }) => + serializedBytes(inputSchema) > ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES + ? [[name, serializedBytes(inputSchema)]] + : [], + ), + ).toEqual([]); + } + const captures = advertisedInputJsonSchema( + toolContract("compare_web_captures"), + ); + // The complete capture schemas are each shared by two sides. + expect(captures).toMatchObject({ + description: expect.stringContaining("summarized form"), + properties: { + before: { + properties: { + inspection: { type: "object", properties: expect.any(Object) }, + }, + }, + after: { + type: "object", + description: expect.stringMatching(/ Same schema as `before`\.$/u), + }, + before_scenario: { required: expect.arrayContaining(["steps"]) }, + after_scenario: { + type: "object", + description: expect.stringMatching( + / Same schema as `before_scenario`\.$/u, + ), + }, + }, + }); +}); + it("keeps output schemas and tool descriptions identical to the default profile", async () => { const compact = compactServer(); const full = fullServer(); diff --git a/tests/boundary/mcp/toolSchemaValidity.test.ts b/tests/boundary/mcp/toolSchemaValidity.test.ts index 549ab1d489..576e018376 100644 --- a/tests/boundary/mcp/toolSchemaValidity.test.ts +++ b/tests/boundary/mcp/toolSchemaValidity.test.ts @@ -5,7 +5,14 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { advertisedInputExamples } from "../../../src/contracts/advertisedInputExamples.js"; -import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js"; +import { + TOOL_CONTRACTS, + toolContract, +} from "../../../src/contracts/toolContracts.js"; +import { + ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES, + presentInputJsonSchema, +} from "../../../src/contracts/inputSchemaPresentation.js"; import { emptyArraySchema } from "../../../src/domain/emptyArraySchema.js"; import { processScenarioSchema } from "../../../src/domain/process/processScenario.js"; import { GENERATED_MCP_TOOL_CATALOG } from "../../fixtures/mcpToolCatalog.js"; @@ -539,8 +546,19 @@ describe("MCP JSON Schema validity", () => { expect(recursiveReferences(tools)).toEqual([]); expectKnownAuthorityHints(tools); const byName = new Map(tools.map((tool) => [tool.name, tool])); + // The advertisement is fitted to the client size limit. Check the + // relaxed producer union in the complete self-contained projection. + const completeCaptureInput = presentInputJsonSchema( + toolContract("compare_web_captures").inputSchema[ + "~standard" + ].jsonSchema.input({ + target: "draft-2020-12", + libraryOptions: { reused: "inline" }, + }) as Record, + () => undefined, + ); for (const side of ["before", "after"]) - expect(byName.get("compare_web_captures")?.inputSchema).toHaveProperty( + expect(completeCaptureInput).toHaveProperty( [ "properties", side, @@ -887,6 +905,16 @@ it("ships input schemas without local references in the generated catalog", () = expect(localInputReferences(GENERATED_MCP_TOOL_CATALOG)).toEqual([]); }); +it("ships every input schema within the client per-tool size limit in the generated catalog", () => { + // Claude Desktop drops a tool whose compact-JSON input schema exceeds 64 KiB. + expect( + GENERATED_MCP_TOOL_CATALOG.flatMap(({ name, inputSchema }) => { + const bytes = Buffer.byteLength(JSON.stringify(inputSchema), "utf8"); + return bytes > ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES ? [[name, bytes]] : []; + }), + ).toEqual([]); +}); + it("rejects unresolved output-schema references even for uncalled tools", () => { const errors = schemaErrors([ { @@ -955,6 +983,8 @@ it("checks client array constraints in schema positions while preserving literal for (const [literal, reason] of [ [{ const: "" }, "const is an empty string"], [{ enum: ["named", ""] }, "enum contains an empty string"], + [{ type: "integer", const: 2 }, "const is a number"], + [{ enum: ["named", 1.5] }, "enum contains a number"], ] as const) { expect( unsupportedInputLiteralSchemaError( diff --git a/tests/fixtures/mcpClientSchemaConstraints.ts b/tests/fixtures/mcpClientSchemaConstraints.ts index 42b496c93a..7266077316 100644 --- a/tests/fixtures/mcpClientSchemaConstraints.ts +++ b/tests/fixtures/mcpClientSchemaConstraints.ts @@ -40,7 +40,7 @@ export const arrayItemSchemaError = ( return undefined; }; -/** Reject Gemini-incompatible boolean and empty-string input literals. */ +/** Reject Gemini-incompatible boolean, numeric and empty-string input literals. */ export const unsupportedInputLiteralSchemaError = ( schema: unknown, path: string, @@ -48,6 +48,7 @@ export const unsupportedInputLiteralSchemaError = ( if (!isRecord(schema)) return undefined; if (typeof schema.const === "boolean") return `${path}.const is boolean`; if (schema.const === "") return `${path}.const is an empty string`; + if (typeof schema.const === "number") return `${path}.const is a number`; if ( Array.isArray(schema.enum) && schema.enum.some((value) => typeof value === "boolean") @@ -55,6 +56,11 @@ export const unsupportedInputLiteralSchemaError = ( return `${path}.enum contains a boolean`; if (Array.isArray(schema.enum) && schema.enum.includes("")) return `${path}.enum contains an empty string`; + if ( + Array.isArray(schema.enum) && + schema.enum.some((value) => typeof value === "number") + ) + return `${path}.enum contains a number`; for (const [key, value] of Object.entries(schema)) { const children = schemaMaps.has(key) && isRecord(value)