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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-25 05:25:07 -07:00
1 parent aa79f59478
commit 7336a566ac
13 files changed
+1199 -81

No files matched your search

+22 -4
View File
@@ -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
+2 -2
View File
@@ -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)),
),
+3 -5
View File
@@ -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,
),
@@ -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<string, unknown>;
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<Schema> {
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<unknown> {
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<Schema>,
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<string>();
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<string, any>;
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);
@@ -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<string, unknown> => ({
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<string, any> =>
Object.fromEntries(
Array.from({ length: width }, (_, index) => [
`field_${index}`,
levels <= 1 ? "value" : instance(levels - 1, width),
]),
);
const expectWidened = (
original: Record<string, unknown>,
fitted: Record<string, unknown>,
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<string, any>).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<string, any>).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<string, any>;
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<string, any>).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 } },
]);
});
});
+423 -46
View File
@@ -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<Record<string, unknown>> =>
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<Record<string, unknown>>): 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<Record<string, unknown>>,
): 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<string, unknown>>,
): Record<string, unknown> => {
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<Record<string, unknown>>): Readonly<Record<string, unknown>> =>
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<string, unknown>>][],
): Record<string, unknown> | 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<string, unknown>): 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<string, unknown> = { ...value };
let changed = false;
const normalizeMap = (map: Readonly<Record<string, unknown>>) =>
Object.fromEntries(
const normalizeMap = (
key: string,
map: Readonly<Record<string, unknown>>,
) => {
const complete: [string, Readonly<Record<string, unknown>>][] = [];
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<string, unknown>;
};
/**
* 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<object, Record<string, unknown>>();
/**
* 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<string, unknown>,
compactPresentation: CompactInputSchemaPresentation | undefined,
): Record<string, unknown> =>
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<string, unknown>,
): Record<string, unknown> => {
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<string, unknown>,
levels: number,
): Record<string, unknown> =>
normalizeAdvertisedSchema(schema, { levels }).schema as Record<
string,
unknown
>;
/** Fall back to the compact projection, which fits any budget. */
const fitByCompaction = (
schema: Record<string, unknown>,
): Record<string, unknown> => {
const { schema: fitted } = compactAdvertisedInputSchema(
schema,
ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES,
);
completeProjections.set(fitted, schema);
return fitted;
};
+9 -6
View File
@@ -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 = <Contract extends ToolContract>(
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 }),
};
});
},
);
+3 -5
View File
@@ -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 =
@@ -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: [] } },
{
+11 -6
View File
@@ -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,
@@ -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();
+32 -2
View File
@@ -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<string, unknown>,
() => 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(
+7 -1
View File
@@ -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)