mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
revert(mcp): undo input schema fitting that broke main CI
This reverts commit7336a566ac(#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 againstbc143125e, 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:
+4
-22
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
|
||||
@@ -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 } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,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 }),
|
||||
});
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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: [] } },
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user