diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 1b629e55ad..174d827130 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -1220,34 +1220,16 @@ 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, 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. +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. 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 ea61833630..4225f87561 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 { presentAdvertisedInputProfile } from "../dist/contracts/inputSchemaPresentation.js"; +import { presentAdvertisedInputSchema } from "../dist/contracts/compactInputSchema.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, - presentAdvertisedInputProfile( + presentAdvertisedInputSchema( advertisedInputJsonSchema( toolContract(tool.name.slice(prefix.length)), ), diff --git a/src/catalogIdentity.ts b/src/catalogIdentity.ts index 27ef212e1c..afd53aa764 100644 --- a/src/catalogIdentity.ts +++ b/src/catalogIdentity.ts @@ -12,8 +12,10 @@ import { advertisedInputJsonSchema, toolOutputSchemaWithMetadata, } from "./contracts/toolSchemaMetadata.js"; -import type { CompactInputSchemaPresentation } from "./contracts/compactInputSchema.js"; -import { presentAdvertisedInputProfile } from "./contracts/inputSchemaPresentation.js"; +import { + presentAdvertisedInputSchema, + type CompactInputSchemaPresentation, +} from "./contracts/compactInputSchema.js"; import type { McpOutputSchemaProfile } from "./config/mcpOutputSchemaProfile.js"; import { COMPACT_INPUT_SCHEMA_BUDGET_BYTES, @@ -60,7 +62,7 @@ const presentToolCatalog = ( ? catalog : catalog.map((tool) => ({ ...tool, - input_schema: presentAdvertisedInputProfile( + input_schema: presentAdvertisedInputSchema( tool.input_schema, presentation, ), diff --git a/src/contracts/inputSchemaFitInvariants.test.ts b/src/contracts/inputSchemaFitInvariants.test.ts deleted file mode 100644 index 398fcd2412..0000000000 --- a/src/contracts/inputSchemaFitInvariants.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -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 deleted file mode 100644 index bae749b206..0000000000 --- a/src/contracts/inputSchemaPresentation.test.ts +++ /dev/null @@ -1,371 +0,0 @@ -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 186756bedb..dc40eefe02 100644 --- a/src/contracts/inputSchemaPresentation.ts +++ b/src/contracts/inputSchemaPresentation.ts @@ -9,11 +9,6 @@ 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); @@ -160,223 +155,6 @@ 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" || @@ -404,122 +182,36 @@ const preserveSharedDefault = ( schema.default = defaults[0]; }; -/** - * 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 => { +const normalizeAdvertisedSchema = (value: unknown): 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 = ( - key: string, - map: Readonly>, - ) => { - const complete: [string, Readonly>][] = []; - return Object.fromEntries( + const normalizeMap = (map: Readonly>) => + Object.fromEntries( Object.entries(map).map(([name, 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)); + const result = normalizeAdvertisedSchema(child); changed ||= result.changed; return [name, result.schema]; }), ); - }; - const normalizeList = (key: string, list: readonly unknown[]) => + const normalizeList = (list: readonly unknown[]) => list.map((child) => { - const result = normalizeAdvertisedSchema(child, nested(key)); + const result = normalizeAdvertisedSchema(child); changed ||= result.changed; return result; }); for (const [key, child] of Object.entries(value)) { if (schemaMaps.has(key) && isObject(child)) { - node[key] = normalizeMap(key, child); + node[key] = normalizeMap(child); } else if ( schemaArrays.has(key) && key !== "anyOf" && key !== "oneOf" && Array.isArray(child) ) { - node[key] = normalizeList(key, child).map((result) => result.schema); + node[key] = normalizeList(child).map((result) => result.schema); } else if ( schemaChildren.has(key) && key !== "not" && @@ -528,8 +220,8 @@ const normalizeAdvertisedSchema = ( key !== "else" ) { const results = Array.isArray(child) - ? normalizeList(key, child) - : [normalizeAdvertisedSchema(child, nested(key))]; + ? normalizeList(child) + : [normalizeAdvertisedSchema(child)]; const nestedChanged = results.some((result) => result.changed); if ( key === "contains" && @@ -549,10 +241,40 @@ const normalizeAdvertisedSchema = ( } } - if (relaxUnsupportedLiterals(node)) changed = true; + 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; + } const negated = Object.hasOwn(value, "not") - ? normalizeAdvertisedSchema(value.not, summary) + ? normalizeAdvertisedSchema(value.not) : undefined; if (negated?.changed) { // Widening a predicate under `not` narrows the containing schema. @@ -563,13 +285,13 @@ const normalizeAdvertisedSchema = ( } const conditional = Object.hasOwn(value, "if") - ? normalizeAdvertisedSchema(value.if, summary) + ? normalizeAdvertisedSchema(value.if) : undefined; const thenBranch = Object.hasOwn(value, "then") - ? normalizeAdvertisedSchema(value.then, summary) + ? normalizeAdvertisedSchema(value.then) : undefined; const elseBranch = Object.hasOwn(value, "else") - ? normalizeAdvertisedSchema(value.else, summary) + ? normalizeAdvertisedSchema(value.else) : undefined; if (conditional?.changed) { // Changing the predicate can route canonical-valid inputs into a rejecting branch. @@ -592,7 +314,7 @@ const normalizeAdvertisedSchema = ( for (const combinator of ["anyOf", "oneOf"] as const) { const branches = value[combinator]; if (!Array.isArray(branches)) continue; - const normalized = normalizeList(combinator, branches); + const normalized = normalizeList(branches); const schemas = normalized.map((result) => result.schema); const branchesChanged = normalized.some((result) => result.changed); if (combinator === "oneOf" && branchesChanged) { @@ -631,13 +353,6 @@ const normalizeAdvertisedSchema = ( 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 }; }; @@ -908,95 +623,3 @@ 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 9fbc16b2be..5344f66c01 100644 --- a/src/contracts/toolSchemaMetadata.ts +++ b/src/contracts/toolSchemaMetadata.ts @@ -6,10 +6,7 @@ import { z } from "zod"; import type { ToolContract } from "./toolContractTypes.js"; import { advertisedInputExamples } from "./advertisedInputExamples.js"; -import { - fitAdvertisedInputSchema, - presentInputJsonSchema, -} from "./inputSchemaPresentation.js"; +import { presentInputJsonSchema } from "./inputSchemaPresentation.js"; import { schemaArrays, schemaChildren, @@ -78,13 +75,13 @@ export const toolInputSchemaWithMetadata = ( libraryOptions: { ...options.libraryOptions, reused: "inline" }, }); const examples = advertisedInputExamples(contract); - // 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({ + return { + // Keep advertised parameters self-contained for clients that do not + // resolve local references. The registered canonical schema remains + // responsible for runtime validation. ...presentInputJsonSchema(inline, fallbackPropertyDescription), ...(examples.length === 0 ? {} : { examples }), - }); + }; }, ); diff --git a/src/server/EvidenceMcpServerCore.ts b/src/server/EvidenceMcpServerCore.ts index 5c1709ec3d..7edde2dd1d 100644 --- a/src/server/EvidenceMcpServerCore.ts +++ b/src/server/EvidenceMcpServerCore.ts @@ -26,10 +26,12 @@ import { } from "@modelcontextprotocol/server"; import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js"; -import type { CompactInputSchemaPresentation } from "../contracts/compactInputSchema.js"; +import { + presentAdvertisedInputSchema, + 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, @@ -257,7 +259,7 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { typeof transformAdvertisedInputJsonSchema >[0], (projected) => - presentAdvertisedInputProfile(projected, { budgetBytes: budget }), + presentAdvertisedInputSchema(projected, { budgetBytes: budget }), ) : undefined; const registeredConfig = diff --git a/tests/boundary/mcp/browserComparisonInput.test.ts b/tests/boundary/mcp/browserComparisonInput.test.ts index b127451f0f..8350c1dea1 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, webmcp: {} }, - after: passive.after, + before: passive.before, + after: { ...passive.after, webmcp: {} }, }, { before_scenario: {}, after_scenario: {} }, { before_scenario: scenario.before_scenario }, @@ -146,10 +146,6 @@ 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 8ffec6819a..e8caca9da6 100644 --- a/tests/boundary/mcp/browserScenarioMcp.test.ts +++ b/tests/boundary/mcp/browserScenarioMcp.test.ts @@ -267,17 +267,12 @@ describe("browser scenario MCP tool", () => { validateComparison(comparisonInput), JSON.stringify(validateComparison.errors), ).toBe(true); - 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); + for (const malformed of [ + { before_scenario: captureResult }, + { before_scenario: captureResult, after: captureResult }, + { ...comparisonInput, before: captureResult, after: captureResult }, + ]) { + expect(validateFullComparison(malformed)).toBe(false); 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 3c68c236f7..6c5650469a 100644 --- a/tests/boundary/mcp/compactInputSchemaMcp.test.ts +++ b/tests/boundary/mcp/compactInputSchemaMcp.test.ts @@ -10,10 +10,7 @@ import { } from "../../../src/contracts/toolContracts.js"; import { PROMPT_CONTRACTS } from "../../../src/contracts/promptContracts.js"; import { advertisedInputJsonSchema } from "../../../src/contracts/toolSchemaMetadata.js"; -import { - ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES, - presentAdvertisedInputProfile, -} from "../../../src/contracts/inputSchemaPresentation.js"; +import { presentAdvertisedInputSchema } from "../../../src/contracts/compactInputSchema.js"; import { createCatalogDigests } from "../../../src/catalogIdentity.js"; import { schemaArrays, @@ -147,7 +144,7 @@ describe("MCP compact input schema profile", () => { ].entries()) { for (const contract of TOOL_CONTRACTS) { expect(toolsByName[index]?.get(contract.name)?.inputSchema).toEqual( - presentAdvertisedInputProfile( + presentAdvertisedInputSchema( advertisedInputJsonSchema(contract), presentation, ), @@ -211,47 +208,6 @@ 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 576e018376..549ab1d489 100644 --- a/tests/boundary/mcp/toolSchemaValidity.test.ts +++ b/tests/boundary/mcp/toolSchemaValidity.test.ts @@ -5,14 +5,7 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { advertisedInputExamples } from "../../../src/contracts/advertisedInputExamples.js"; -import { - TOOL_CONTRACTS, - toolContract, -} from "../../../src/contracts/toolContracts.js"; -import { - ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES, - presentInputJsonSchema, -} from "../../../src/contracts/inputSchemaPresentation.js"; +import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.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"; @@ -546,19 +539,8 @@ 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(completeCaptureInput).toHaveProperty( + expect(byName.get("compare_web_captures")?.inputSchema).toHaveProperty( [ "properties", side, @@ -905,16 +887,6 @@ 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([ { @@ -983,8 +955,6 @@ 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 7266077316..42b496c93a 100644 --- a/tests/fixtures/mcpClientSchemaConstraints.ts +++ b/tests/fixtures/mcpClientSchemaConstraints.ts @@ -40,7 +40,7 @@ export const arrayItemSchemaError = ( return undefined; }; -/** Reject Gemini-incompatible boolean, numeric and empty-string input literals. */ +/** Reject Gemini-incompatible boolean and empty-string input literals. */ export const unsupportedInputLiteralSchemaError = ( schema: unknown, path: string, @@ -48,7 +48,6 @@ 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") @@ -56,11 +55,6 @@ 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)