revert(mcp): undo input schema fitting that broke main CI

This reverts commit 7336a566ac (#7119).

After #7120 and the other merges of 2026-10-25 grew inspect_analysis_view's
complete input schema (71,762 bytes plus 3,003 bytes of examples), the 64 KiB
fitting from #7119 could no longer keep instance level 3 and fell back to
level 2. That summarized the inline Evidence source and the view selector
collections, so main CI failed in toolInputReferences, toolSchemaValidity,
analysisViewMcp, absoluteLocalPathSchema, javascriptFactsViewMcp and
godotPckMcp. #7119's own CI ran against bc143125e, before that growth.

Reverting restores the complete full-profile advertisement while the size
limit is redesigned against these contracts (#4081 stays open).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-25 12:59:20 +00:00
parent 25f0f9934d
commit fac354de44
13 changed files with 81 additions and 1199 deletions
+4 -22
View File
@@ -1220,34 +1220,16 @@ after SDK conversion and check advertised validation and actual calls.
Individual model APIs can impose additional nesting limits; complete producer
captures can exceed ten structural levels.
Claude Desktop drops a local connector tool, with a generic "problem with that
connector" warning, when its input schema serialized as compact JSON exceeds
64 KiB ([#4081](https://github.com/morluto/rea/issues/4081)). Every advertised
input schema therefore fits 65,536 UTF-8 bytes, including its examples. A
self-contained schema that would exceed the limit is fitted in two steps, and
its root description says it is summarized. First, a property whose schema
repeats an earlier sibling's, such as `after` beside `before`, is advertised as
its types and description plus "Same schema as `before`." Second, schemas below
the deepest instance level that still fits are advertised as their types, size
constraints and descriptions, unless that summary would be longer than the
schema itself. Combinator and conditional branches count as the
level of the instance they describe. Both steps only widen the advertisement:
canonical inputs and examples stay accepted, required names and nested
constraints below the cut are not advertised, and the canonical contract still
validates every call. Schemas within the limit are advertised unchanged. The
compact profile starts from the complete projection, not from the fitted one.
`search_bytes` advertises its pattern and mask as descriptive strings. Its
shared canonical validator scans byte pairs, whitespace, wildcards and aligned
masks without a repeating regular expression or temporary byte arrays. The
server rejects malformed or unconstrained patterns before provider dispatch;
the advertised strings alone do not express those rules.
Boolean, numeric and empty-string literal constraints in input schemas are
relaxed only in this advertisement. Boolean `const` becomes `type: boolean`;
numeric `const` keeps its numeric type; empty-string `const`, an enum containing
an empty string, and an enum containing a number are widened without dropping
an accepted value. Defaults and descriptions remain intact.
Boolean and empty-string literal constraints in input schemas are relaxed only
in this advertisement. Boolean `const` becomes `type: boolean`; empty-string
`const` or an enum containing an empty string is widened without dropping the
accepted empty value. Defaults and descriptions remain intact.
An affected `not` is omitted, conditional predicates and their branches are
omitted together, pure boolean-literal `oneOf` branches collapse to a boolean,
and other affected `oneOf` branches are presented as `anyOf` to retain branch
+2 -2
View File
@@ -10,7 +10,7 @@ import {
toolContract,
} from "../dist/contracts/toolContracts.js";
import { advertisedInputJsonSchema } from "../dist/contracts/toolSchemaMetadata.js";
import { presentAdvertisedInputProfile } from "../dist/contracts/inputSchemaPresentation.js";
import { presentAdvertisedInputSchema } from "../dist/contracts/compactInputSchema.js";
import { compactPresentationForMcpInputSchemaProfile } from "../dist/config/mcpInputSchemaProfile.js";
import { createVerifierCommands } from "./lib/verify-commands.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
@@ -186,7 +186,7 @@ await withVerifierCleanup(async () => {
);
assert.deepEqual(
tool.parameters,
presentAdvertisedInputProfile(
presentAdvertisedInputSchema(
advertisedInputJsonSchema(
toolContract(tool.name.slice(prefix.length)),
),
+5 -3
View File
@@ -12,8 +12,10 @@ import {
advertisedInputJsonSchema,
toolOutputSchemaWithMetadata,
} from "./contracts/toolSchemaMetadata.js";
import type { CompactInputSchemaPresentation } from "./contracts/compactInputSchema.js";
import { presentAdvertisedInputProfile } from "./contracts/inputSchemaPresentation.js";
import {
presentAdvertisedInputSchema,
type CompactInputSchemaPresentation,
} from "./contracts/compactInputSchema.js";
import type { McpOutputSchemaProfile } from "./config/mcpOutputSchemaProfile.js";
import {
COMPACT_INPUT_SCHEMA_BUDGET_BYTES,
@@ -60,7 +62,7 @@ const presentToolCatalog = (
? catalog
: catalog.map((tool) => ({
...tool,
input_schema: presentAdvertisedInputProfile(
input_schema: presentAdvertisedInputSchema(
tool.input_schema,
presentation,
),
@@ -1,264 +0,0 @@
import { Ajv } from "ajv";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it } from "vitest";
import { advertisedInputExamples } from "./advertisedInputExamples.js";
import {
ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES,
fitAdvertisedInputSchema,
presentInputJsonSchema,
summarizedInputSchemaCandidate,
summarizedSchema,
} from "./inputSchemaPresentation.js";
import {
schemaArrays,
schemaChildren,
schemaLiteralValues,
schemaMaps,
} from "./jsonSchemaKeywords.js";
import { TOOL_CONTRACTS } from "./toolContracts.js";
type Schema = Record<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);
@@ -1,371 +0,0 @@
import { Ajv2020 } from "ajv/dist/2020.js";
import { describe, expect, it } from "vitest";
import { presentAdvertisedInputSchema } from "./compactInputSchema.js";
import {
ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES,
fitAdvertisedInputSchema,
presentAdvertisedInputProfile,
presentInputJsonSchema,
} from "./inputSchemaPresentation.js";
const bytes = (value: unknown): number =>
Buffer.byteLength(JSON.stringify(value), "utf8");
const ajv = () => new Ajv2020({ strict: false, validateFormats: false });
/** A strict object `levels` deep with `width` distinct padded string leaves. */
const record = (
levels: number,
width: number,
pad = 80,
path = "f",
): Record<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 } },
]);
});
});
+46 -423
View File
@@ -9,11 +9,6 @@ import {
schemaChildren,
schemaMaps,
} from "./jsonSchemaKeywords.js";
import {
compactAdvertisedInputSchema,
presentAdvertisedInputSchema,
type CompactInputSchemaPresentation,
} from "./compactInputSchema.js";
const isObject = (value: unknown): value is Readonly<Record<string, unknown>> =>
typeof value === "object" && value !== null && !Array.isArray(value);
@@ -160,223 +155,6 @@ type SchemaNormalization = {
readonly changed: boolean;
};
const serializedBytes = (value: unknown): number =>
Buffer.byteLength(JSON.stringify(value), "utf8");
/** Instance nesting levels below the current node that keep their structure. */
type SchemaSummary = { readonly levels: number };
// Keywords whose subschemas describe a nested instance. Combinators,
// conditionals and dependent schemas describe the same instance as their parent.
const instanceChildKeys = new Set([
"properties",
"patternProperties",
"additionalProperties",
"unevaluatedProperties",
"propertyNames",
"prefixItems",
"items",
"additionalItems",
"contains",
"unevaluatedItems",
]);
const hasNestedSchema = (schema: Readonly<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" ||
@@ -404,122 +182,36 @@ const preserveSharedDefault = (
schema.default = defaults[0];
};
/**
* Relax literal constraints that Gemini declarations reject, in place, without
* rejecting a value the literal accepted. Returns whether anything changed.
*/
const relaxUnsupportedLiterals = (node: Record<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 => {
const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
if (!isObject(value)) return { schema: value, changed: false };
if (summary !== undefined && summary.levels <= 0 && hasNestedSchema(value)) {
// A summary can be longer than the schema it replaces, such as a nullable
// array presented as anyOf. Keep the complete schema then.
const summarized = summarizedSchema(value);
return serializedBytes(summarized) < serializedBytes(value)
? { schema: summarized, changed: true }
: normalizeAdvertisedSchema(value);
}
const nested = (key: string): SchemaSummary | undefined =>
summary !== undefined && instanceChildKeys.has(key)
? { levels: summary.levels - 1 }
: summary;
const node: Record<string, unknown> = { ...value };
let changed = false;
const normalizeMap = (
key: string,
map: Readonly<Record<string, unknown>>,
) => {
const complete: [string, Readonly<Record<string, unknown>>][] = [];
return Object.fromEntries(
const normalizeMap = (map: Readonly<Record<string, unknown>>) =>
Object.fromEntries(
Object.entries(map).map(([name, child]) => {
const shared =
summary !== undefined && key === "properties"
? sharedSiblingSchema(name, child, complete)
: undefined;
if (shared !== undefined) {
changed = true;
return [name, shared];
}
const result = normalizeAdvertisedSchema(child, nested(key));
const result = normalizeAdvertisedSchema(child);
changed ||= result.changed;
return [name, result.schema];
}),
);
};
const normalizeList = (key: string, list: readonly unknown[]) =>
const normalizeList = (list: readonly unknown[]) =>
list.map((child) => {
const result = normalizeAdvertisedSchema(child, nested(key));
const result = normalizeAdvertisedSchema(child);
changed ||= result.changed;
return result;
});
for (const [key, child] of Object.entries(value)) {
if (schemaMaps.has(key) && isObject(child)) {
node[key] = normalizeMap(key, child);
node[key] = normalizeMap(child);
} else if (
schemaArrays.has(key) &&
key !== "anyOf" &&
key !== "oneOf" &&
Array.isArray(child)
) {
node[key] = normalizeList(key, child).map((result) => result.schema);
node[key] = normalizeList(child).map((result) => result.schema);
} else if (
schemaChildren.has(key) &&
key !== "not" &&
@@ -528,8 +220,8 @@ const normalizeAdvertisedSchema = (
key !== "else"
) {
const results = Array.isArray(child)
? normalizeList(key, child)
: [normalizeAdvertisedSchema(child, nested(key))];
? normalizeList(child)
: [normalizeAdvertisedSchema(child)];
const nestedChanged = results.some((result) => result.changed);
if (
key === "contains" &&
@@ -549,10 +241,40 @@ const normalizeAdvertisedSchema = (
}
}
if (relaxUnsupportedLiterals(node)) changed = true;
if (typeof node.const === "boolean") {
delete node.const;
if (node.type === undefined) node.type = "boolean";
changed = true;
}
if (node.const === "") {
delete node.const;
if (node.type === undefined) node.type = "string";
changed = true;
}
if (
Array.isArray(node.enum) &&
node.enum.length > 0 &&
node.enum.every((item) => typeof item === "boolean")
) {
delete node.enum;
if (node.type === undefined) node.type = "boolean";
changed = true;
}
if (Array.isArray(node.enum) && node.enum.includes("")) {
// Removing only the empty member would reject a canonical-valid input.
// Gemini transports turn const into enum and refuse empty enum members.
const values = node.enum;
delete node.enum;
if (
node.type === undefined &&
values.every((item) => typeof item === "string")
)
node.type = "string";
changed = true;
}
const negated = Object.hasOwn(value, "not")
? normalizeAdvertisedSchema(value.not, summary)
? normalizeAdvertisedSchema(value.not)
: undefined;
if (negated?.changed) {
// Widening a predicate under `not` narrows the containing schema.
@@ -563,13 +285,13 @@ const normalizeAdvertisedSchema = (
}
const conditional = Object.hasOwn(value, "if")
? normalizeAdvertisedSchema(value.if, summary)
? normalizeAdvertisedSchema(value.if)
: undefined;
const thenBranch = Object.hasOwn(value, "then")
? normalizeAdvertisedSchema(value.then, summary)
? normalizeAdvertisedSchema(value.then)
: undefined;
const elseBranch = Object.hasOwn(value, "else")
? normalizeAdvertisedSchema(value.else, summary)
? normalizeAdvertisedSchema(value.else)
: undefined;
if (conditional?.changed) {
// Changing the predicate can route canonical-valid inputs into a rejecting branch.
@@ -592,7 +314,7 @@ const normalizeAdvertisedSchema = (
for (const combinator of ["anyOf", "oneOf"] as const) {
const branches = value[combinator];
if (!Array.isArray(branches)) continue;
const normalized = normalizeList(combinator, branches);
const normalized = normalizeList(branches);
const schemas = normalized.map((result) => result.schema);
const branchesChanged = normalized.some((result) => result.changed);
if (combinator === "oneOf" && branchesChanged) {
@@ -631,13 +353,6 @@ const normalizeAdvertisedSchema = (
changed ||= branchesChanged;
}
if (changed) {
// A relaxed or summarized applicator can stop evaluating properties or
// items, which these keywords would then reject.
delete node.unevaluatedProperties;
delete node.unevaluatedItems;
}
return { schema: node, changed };
};
@@ -908,95 +623,3 @@ export const presentInputJsonSchema = (
description: description.join(" "),
}).schema as Record<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;
};
+6 -9
View File
@@ -6,10 +6,7 @@ import { z } from "zod";
import type { ToolContract } from "./toolContractTypes.js";
import { advertisedInputExamples } from "./advertisedInputExamples.js";
import {
fitAdvertisedInputSchema,
presentInputJsonSchema,
} from "./inputSchemaPresentation.js";
import { presentInputJsonSchema } from "./inputSchemaPresentation.js";
import {
schemaArrays,
schemaChildren,
@@ -78,13 +75,13 @@ export const toolInputSchemaWithMetadata = <Contract extends ToolContract>(
libraryOptions: { ...options.libraryOptions, reused: "inline" },
});
const examples = advertisedInputExamples(contract);
// Keep advertised parameters self-contained for clients that do not
// resolve local references, within the per-tool client size limit. The
// registered canonical schema remains responsible for runtime validation.
return fitAdvertisedInputSchema({
return {
// Keep advertised parameters self-contained for clients that do not
// resolve local references. The registered canonical schema remains
// responsible for runtime validation.
...presentInputJsonSchema(inline, fallbackPropertyDescription),
...(examples.length === 0 ? {} : { examples }),
});
};
},
);
+5 -3
View File
@@ -26,10 +26,12 @@ import {
} from "@modelcontextprotocol/server";
import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js";
import type { CompactInputSchemaPresentation } from "../contracts/compactInputSchema.js";
import {
presentAdvertisedInputSchema,
type CompactInputSchemaPresentation,
} from "../contracts/compactInputSchema.js";
import type { createCatalogIdentity } from "../catalogIdentity.js";
import { transformAdvertisedInputJsonSchema } from "../contracts/toolSchemaMetadata.js";
import { presentAdvertisedInputProfile } from "../contracts/inputSchemaPresentation.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import {
AnalysisInputError,
@@ -257,7 +259,7 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer {
typeof transformAdvertisedInputJsonSchema
>[0],
(projected) =>
presentAdvertisedInputProfile(projected, { budgetBytes: budget }),
presentAdvertisedInputSchema(projected, { budgetBytes: budget }),
)
: undefined;
const registeredConfig =
@@ -67,8 +67,8 @@ function invalidComparisonInputs(
{ before: passive.before },
{ before: { inspection: {} }, after: passive.after },
{
before: { ...passive.before, webmcp: {} },
after: passive.after,
before: passive.before,
after: { ...passive.after, webmcp: {} },
},
{ before_scenario: {}, after_scenario: {} },
{ before_scenario: scenario.before_scenario },
@@ -146,10 +146,6 @@ describe("browser comparison input boundary", () => {
"before + after; before_scenario + after_scenario + [normalization]",
);
const runtimeOnlyInvalid = [
// The advertisement fits the 64 KiB client limit by presenting
// `after` as "Same schema as `before`" (#4081); the canonical
// parser still rejects a malformed `after` capture.
{ before: passive.before, after: { ...passive.after, webmcp: {} } },
{ ...passive, ...scenario },
{ ...passive, normalization: { rules: [] } },
{
+6 -11
View File
@@ -267,17 +267,12 @@ describe("browser scenario MCP tool", () => {
validateComparison(comparisonInput),
JSON.stringify(validateComparison.errors),
).toBe(true);
for (const [malformed, advertisedRejects] of [
[{ before_scenario: captureResult }, true],
// The fitted advertisement summarizes `after` as "Same schema as
// `before`"; only the canonical contract rejects a scenario in it.
[{ before_scenario: captureResult, after: captureResult }, false],
[
{ ...comparisonInput, before: captureResult, after: captureResult },
true,
],
] as const) {
expect(validateFullComparison(malformed)).toBe(!advertisedRejects);
for (const malformed of [
{ before_scenario: captureResult },
{ before_scenario: captureResult, after: captureResult },
{ ...comparisonInput, before: captureResult, after: captureResult },
]) {
expect(validateFullComparison(malformed)).toBe(false);
const rejected = await client.callTool({
name: "compare_web_captures",
arguments: malformed,
@@ -10,10 +10,7 @@ import {
} from "../../../src/contracts/toolContracts.js";
import { PROMPT_CONTRACTS } from "../../../src/contracts/promptContracts.js";
import { advertisedInputJsonSchema } from "../../../src/contracts/toolSchemaMetadata.js";
import {
ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES,
presentAdvertisedInputProfile,
} from "../../../src/contracts/inputSchemaPresentation.js";
import { presentAdvertisedInputSchema } from "../../../src/contracts/compactInputSchema.js";
import { createCatalogDigests } from "../../../src/catalogIdentity.js";
import {
schemaArrays,
@@ -147,7 +144,7 @@ describe("MCP compact input schema profile", () => {
].entries()) {
for (const contract of TOOL_CONTRACTS) {
expect(toolsByName[index]?.get(contract.name)?.inputSchema).toEqual(
presentAdvertisedInputProfile(
presentAdvertisedInputSchema(
advertisedInputJsonSchema(contract),
presentation,
),
@@ -211,47 +208,6 @@ it("advertises every tool input schema within the provider budget", async () =>
).toEqual([]);
});
it("advertises every input schema within the client per-tool limit in both profiles", async () => {
// Claude Desktop drops a tool whose compact-JSON input schema exceeds 64 KiB.
for (const server of [fullServer(), compactServer()]) {
registerCatalog(server);
const tools = await listToolsThroughTransport(server);
expect(tools).toHaveLength(TOOL_CONTRACTS.length);
expect(
tools.flatMap(({ name, inputSchema }) =>
serializedBytes(inputSchema) > ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES
? [[name, serializedBytes(inputSchema)]]
: [],
),
).toEqual([]);
}
const captures = advertisedInputJsonSchema(
toolContract("compare_web_captures"),
);
// The complete capture schemas are each shared by two sides.
expect(captures).toMatchObject({
description: expect.stringContaining("summarized form"),
properties: {
before: {
properties: {
inspection: { type: "object", properties: expect.any(Object) },
},
},
after: {
type: "object",
description: expect.stringMatching(/ Same schema as `before`\.$/u),
},
before_scenario: { required: expect.arrayContaining(["steps"]) },
after_scenario: {
type: "object",
description: expect.stringMatching(
/ Same schema as `before_scenario`\.$/u,
),
},
},
});
});
it("keeps output schemas and tool descriptions identical to the default profile", async () => {
const compact = compactServer();
const full = fullServer();
+2 -32
View File
@@ -5,14 +5,7 @@ import { describe, expect, it } from "vitest";
import { z } from "zod";
import { advertisedInputExamples } from "../../../src/contracts/advertisedInputExamples.js";
import {
TOOL_CONTRACTS,
toolContract,
} from "../../../src/contracts/toolContracts.js";
import {
ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES,
presentInputJsonSchema,
} from "../../../src/contracts/inputSchemaPresentation.js";
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
import { emptyArraySchema } from "../../../src/domain/emptyArraySchema.js";
import { processScenarioSchema } from "../../../src/domain/process/processScenario.js";
import { GENERATED_MCP_TOOL_CATALOG } from "../../fixtures/mcpToolCatalog.js";
@@ -546,19 +539,8 @@ describe("MCP JSON Schema validity", () => {
expect(recursiveReferences(tools)).toEqual([]);
expectKnownAuthorityHints(tools);
const byName = new Map(tools.map((tool) => [tool.name, tool]));
// The advertisement is fitted to the client size limit. Check the
// relaxed producer union in the complete self-contained projection.
const completeCaptureInput = presentInputJsonSchema(
toolContract("compare_web_captures").inputSchema[
"~standard"
].jsonSchema.input({
target: "draft-2020-12",
libraryOptions: { reused: "inline" },
}) as Record<string, unknown>,
() => undefined,
);
for (const side of ["before", "after"])
expect(completeCaptureInput).toHaveProperty(
expect(byName.get("compare_web_captures")?.inputSchema).toHaveProperty(
[
"properties",
side,
@@ -905,16 +887,6 @@ it("ships input schemas without local references in the generated catalog", () =
expect(localInputReferences(GENERATED_MCP_TOOL_CATALOG)).toEqual([]);
});
it("ships every input schema within the client per-tool size limit in the generated catalog", () => {
// Claude Desktop drops a tool whose compact-JSON input schema exceeds 64 KiB.
expect(
GENERATED_MCP_TOOL_CATALOG.flatMap(({ name, inputSchema }) => {
const bytes = Buffer.byteLength(JSON.stringify(inputSchema), "utf8");
return bytes > ADVERTISED_INPUT_SCHEMA_LIMIT_BYTES ? [[name, bytes]] : [];
}),
).toEqual([]);
});
it("rejects unresolved output-schema references even for uncalled tools", () => {
const errors = schemaErrors([
{
@@ -983,8 +955,6 @@ it("checks client array constraints in schema positions while preserving literal
for (const [literal, reason] of [
[{ const: "" }, "const is an empty string"],
[{ enum: ["named", ""] }, "enum contains an empty string"],
[{ type: "integer", const: 2 }, "const is a number"],
[{ enum: ["named", 1.5] }, "enum contains a number"],
] as const) {
expect(
unsupportedInputLiteralSchemaError(
+1 -7
View File
@@ -40,7 +40,7 @@ export const arrayItemSchemaError = (
return undefined;
};
/** Reject Gemini-incompatible boolean, numeric and empty-string input literals. */
/** Reject Gemini-incompatible boolean and empty-string input literals. */
export const unsupportedInputLiteralSchemaError = (
schema: unknown,
path: string,
@@ -48,7 +48,6 @@ export const unsupportedInputLiteralSchemaError = (
if (!isRecord(schema)) return undefined;
if (typeof schema.const === "boolean") return `${path}.const is boolean`;
if (schema.const === "") return `${path}.const is an empty string`;
if (typeof schema.const === "number") return `${path}.const is a number`;
if (
Array.isArray(schema.enum) &&
schema.enum.some((value) => typeof value === "boolean")
@@ -56,11 +55,6 @@ export const unsupportedInputLiteralSchemaError = (
return `${path}.enum contains a boolean`;
if (Array.isArray(schema.enum) && schema.enum.includes(""))
return `${path}.enum contains an empty string`;
if (
Array.isArray(schema.enum) &&
schema.enum.some((value) => typeof value === "number")
)
return `${path}.enum contains a number`;
for (const [key, value] of Object.entries(schema)) {
const children =
schemaMaps.has(key) && isRecord(value)