From 7336a566ace2f9d1412f78b0c56c2651b9b169cd Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Sun, 25 Oct 2026 05:25:07 -0700 Subject: [PATCH] fix(mcp): fit every advertised input schema within the 64 KiB client limit (#7119) * fix(mcp): fit every advertised input schema within the 64 KiB client limit Claude Desktop drops a local connector tool whose compact-JSON input schema exceeds 64 KiB. The full input profile advertised compare_web_captures at 171,867 bytes and inspect_analysis_view at 71,632 bytes, because the self-contained projection inlines the complete producer capture schemas once per side. The full advertisement now fits the limit at its producing boundary. A property whose schema repeats an earlier sibling's points at it, and schemas below the deepest instance level that fits are summarized as their types, size constraints and descriptions. Every rewrite widens the advertisement through the existing relaxation rules for oneOf, not, if/then/else, contains and unevaluated keywords, so canonical inputs stay accepted and the canonical contract still validates every call. Schemas within the limit are unchanged, and the compact profile is still presented from the complete projection. Numeric const and enum literals are also relaxed in the advertisement, like boolean literals, since Gemini accepts only string enum members. Fixes #4081 Co-Authored-By: Claude Opus 5.5 * fix(mcp): keep fitted input summaries widening and at the deepest fitting level Array summaries took item types only from `items`, so a tuple such as `{prefixItems: [{type: string}], items: {type: number}}` was advertised as a number array that rejected `["a", 1]`. Item types now include tuple positions from `prefixItems`, a draft-07 `items` array, and `additionalItems`, and stay unconstrained when later elements are open. A summary can also be longer than the schema it replaces (a nullable array becomes an anyOf), so candidate size is not monotone in the retained depth and stopping at the first overflow could skip a deeper candidate that fits. A schema is now kept whole when its summary is not smaller, and fitting searches from the deepest level down. Property tests check that every summary accepts what its schema accepts over every catalog input subschema and synthetic tuples, and that the fitted schema is the deepest fitting candidate. The browser comparison boundary test now expects a malformed `after` capture to be rejected canonically, since `after` is advertised as the same schema as `before`; the same malformation on `before` stays advertised-invalid. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/mcp-contracts.md | 26 +- scripts/verify-copilot-client.mjs | 4 +- src/catalogIdentity.ts | 8 +- .../inputSchemaFitInvariants.test.ts | 264 ++++++++++ src/contracts/inputSchemaPresentation.test.ts | 371 ++++++++++++++ src/contracts/inputSchemaPresentation.ts | 469 ++++++++++++++++-- src/contracts/toolSchemaMetadata.ts | 15 +- src/server/EvidenceMcpServerCore.ts | 8 +- .../mcp/browserComparisonInput.test.ts | 8 +- tests/boundary/mcp/browserScenarioMcp.test.ts | 17 +- .../mcp/compactInputSchemaMcp.test.ts | 48 +- tests/boundary/mcp/toolSchemaValidity.test.ts | 34 +- tests/fixtures/mcpClientSchemaConstraints.ts | 8 +- 13 files changed, 1199 insertions(+), 81 deletions(-) create mode 100644 src/contracts/inputSchemaFitInvariants.test.ts create mode 100644 src/contracts/inputSchemaPresentation.test.ts 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)