fix(mcp): relax Boolean literals without narrowing valid inputs

Normalize only advertised schema positions while keeping canonical validation. Preserve branch guidance and relax nonmonotone constraints as a unit. Verify full/compact catalogs and canonical examples after SDK conversion.

Refs #4025, #4087

Co-authored-by: HarsH <harshasalkar7@gmail.com>
This commit is contained in:
morlutoandHarsH committed 2026-10-16 03:13:14 +08:00
1 parent 0679a7972b
commit a2e30168a0
7 files changed
+571 -7

No files matched your search

+14
View File
@@ -694,6 +694,20 @@ 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.
Boolean literal constraints in input schemas are relaxed only in this
advertisement. For example, a boolean `const` is presented as `type: boolean`;
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
guidance without making overlapping widened branches reject inputs.
This can make the advertised schema accept inputs the canonical contract
rejects. The MCP handler still validates every call with the canonical
contract. The Gemini CLI verifier checks the outgoing
`parametersJsonSchema` and canonical contract examples against that declaration
using a loopback model fixture; it does not establish live Google API or
OpenCode client behavior. Set `REA_VERIFY_GEMINI_SCHEMA_PROFILE=compact` to
verify the compact advertisement through the same client boundary.
### Compact input schema profile
Some function-calling providers reject a request while any single tool's
+62
View File
@@ -5,6 +5,11 @@ import { tmpdir } from "node:os";
import { join, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import Ajv2020 from "ajv/dist/2020.js";
import {
schemaArrays,
schemaChildren,
schemaMaps,
} from "../dist/contracts/inputSchemaPresentation.js";
import {
TOOL_CONTRACTS,
toolContract,
@@ -17,6 +22,8 @@ const repo = fileURLToPath(new URL("..", import.meta.url));
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
const command = process.env.REA_VERIFY_GEMINI_COMMAND ?? "gemini";
const mode = process.argv[2] ?? "call";
const schemaProfile = process.env.REA_VERIFY_GEMINI_SCHEMA_PROFILE ?? "full";
assert(["full", "compact"].includes(schemaProfile));
// npm installs of Gemini CLI provide a JavaScript entry point; on Windows only a gemini.cmd shim reaches
// PATH, which Node does not spawn without a shell, so a selected .js entry runs under this Node. It is
// resolved once, because the preflight runs from the caller's directory and the client from the workspace.
@@ -60,6 +67,7 @@ const environment = {
GEMINI_CLI_NO_RELAUNCH: "1",
GEMINI_CLI_TRUST_WORKSPACE: "true",
REA_PROCESS_RUN_ID: verifier.run_id,
REA_MCP_INPUT_SCHEMA_PROFILE: schemaProfile,
};
let clientVersion;
try {
@@ -161,8 +169,43 @@ let skillRequested = false;
let finalSeen = false;
let catalogSize = 0;
let schemasChecked = 0;
let examplesChecked = 0;
let artifactDigest;
const schemaValidator = new Ajv2020({ strict: false, validateFormats: false });
const booleanLiteralPath = (schema, path = "$", seen = new Set()) => {
if (!schema || typeof schema !== "object" || seen.has(schema))
return undefined;
seen.add(schema);
for (const [key, child] of Object.entries(schema)) {
const childPath = `${path}.${key}`;
if (key === "const" && typeof child === "boolean") return childPath;
if (
key === "enum" &&
Array.isArray(child) &&
child.some((item) => typeof item === "boolean")
)
return childPath;
const schemaChildrenAtKey =
schemaMaps.has(key) &&
child &&
typeof child === "object" &&
!Array.isArray(child)
? Object.entries(child).map(([name, item]) => [
`${childPath}.${name}`,
item,
])
: schemaArrays.has(key) && Array.isArray(child)
? child.map((item, index) => [`${childPath}[${index}]`, item])
: schemaChildren.has(key)
? [[childPath, child]]
: [];
for (const [nestedPath, nested] of schemaChildrenAtKey) {
const found = booleanLiteralPath(nested, nestedPath, seen);
if (found !== undefined) return found;
}
}
return undefined;
};
const extractEvidence = (value) => {
if (typeof value === "string") {
if (
@@ -226,6 +269,22 @@ const fixture = await createGeminiModelFixture({
schemaValidator.validateSchema(tool.parametersJsonSchema),
`${tool.name}: invalid advertised JSON Schema: ${JSON.stringify(schemaValidator.errors)}`,
);
const booleanLiteral = booleanLiteralPath(tool.parametersJsonSchema);
assert.equal(
booleanLiteral,
undefined,
`${tool.name}: Gemini SDK declaration retains a boolean const/enum at ${booleanLiteral}`,
);
const name = tool.name.slice(prefix.length);
const contract = toolContract(name);
const validate = schemaValidator.compile(tool.parametersJsonSchema);
for (const example of contract.examples) {
assert(
validate(example.input),
`${tool.name}: Gemini SDK declaration rejects canonical example ${example.title}: ${JSON.stringify(schemaValidator.errors)}`,
);
examplesChecked++;
}
schemasChecked++;
}
}
@@ -356,6 +415,9 @@ try {
client: "Gemini CLI",
clientVersion,
mode,
schemaProfile,
schemasChecked,
examplesChecked,
passed: failure === undefined,
artifacts: lab,
runtimeRoot,
+203 -3
View File
@@ -146,6 +146,195 @@ const inputGroup = (branch: Readonly<Record<string, unknown>>): string => {
return [...required, ...optional].join(" + ");
};
type SchemaNormalization = {
readonly schema: unknown;
readonly changed: boolean;
};
const isBooleanLiteralSchema = (schema: unknown): boolean =>
isObject(schema) &&
(typeof schema.const === "boolean" ||
(Array.isArray(schema.enum) &&
schema.enum.length > 0 &&
schema.enum.every((value) => typeof value === "boolean")));
const isBooleanSchema = (schema: unknown): boolean =>
isObject(schema) &&
(schema.type === "boolean" || isBooleanLiteralSchema(schema));
const preserveSharedDefault = (
schema: Record<string, unknown>,
branches: readonly unknown[],
): void => {
if (schema.default !== undefined) return;
const defaults = branches
.filter(isObject)
.map((branch) => branch.default)
.filter((value) => value !== undefined);
if (
defaults.length > 0 &&
defaults.every((value) => isDeepStrictEqual(value, defaults[0]))
)
schema.default = defaults[0];
};
const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
if (!isObject(value)) return { schema: value, changed: false };
const node: Record<string, unknown> = { ...value };
let changed = false;
const normalizeMap = (map: Readonly<Record<string, unknown>>) =>
Object.fromEntries(
Object.entries(map).map(([name, child]) => {
const result = normalizeAdvertisedSchema(child);
changed ||= result.changed;
return [name, result.schema];
}),
);
const normalizeList = (list: readonly unknown[]) =>
list.map((child) => {
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(child);
} else if (
schemaArrays.has(key) &&
key !== "anyOf" &&
key !== "oneOf" &&
Array.isArray(child)
) {
node[key] = normalizeList(child).map((result) => result.schema);
} else if (
schemaChildren.has(key) &&
key !== "not" &&
key !== "if" &&
key !== "then" &&
key !== "else"
) {
const results = Array.isArray(child)
? normalizeList(child)
: [normalizeAdvertisedSchema(child)];
const nestedChanged = results.some((result) => result.changed);
if (
key === "contains" &&
nestedChanged &&
value.maxContains !== undefined
) {
delete node.contains;
delete node.minContains;
delete node.maxContains;
changed = true;
} else {
node[key] = Array.isArray(child)
? results.map((result) => result.schema)
: results[0]?.schema;
changed ||= nestedChanged;
}
}
}
if (typeof node.const === "boolean") {
delete node.const;
if (node.type === undefined) node.type = "boolean";
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;
}
const negated = Object.hasOwn(value, "not")
? normalizeAdvertisedSchema(value.not)
: undefined;
if (negated?.changed) {
// Widening a predicate under `not` narrows the containing schema.
changed = true;
delete node.not;
} else if (negated !== undefined) {
node.not = negated.schema;
}
const conditional = Object.hasOwn(value, "if")
? normalizeAdvertisedSchema(value.if)
: undefined;
const thenBranch = Object.hasOwn(value, "then")
? normalizeAdvertisedSchema(value.then)
: undefined;
const elseBranch = Object.hasOwn(value, "else")
? normalizeAdvertisedSchema(value.else)
: undefined;
if (conditional?.changed) {
// Changing the predicate can route canonical-valid inputs into a rejecting branch.
changed = true;
delete node.if;
delete node.then;
delete node.else;
} else {
if (conditional !== undefined) node.if = conditional.schema;
if (thenBranch !== undefined) {
node.then = thenBranch.schema;
changed ||= thenBranch.changed;
}
if (elseBranch !== undefined) {
node.else = elseBranch.schema;
changed ||= elseBranch.changed;
}
}
for (const combinator of ["anyOf", "oneOf"] as const) {
const branches = value[combinator];
if (!Array.isArray(branches)) continue;
const normalized = normalizeList(branches);
const schemas = normalized.map((result) => result.schema);
const branchesChanged = normalized.some((result) => result.changed);
if (combinator === "oneOf" && branchesChanged) {
const booleanLiteralsOnly = branches.every(isBooleanLiteralSchema);
if (booleanLiteralsOnly) {
node.type = "boolean";
preserveSharedDefault(node, schemas);
delete node.oneOf;
changed = true;
continue;
}
// `anyOf` retains branch guidance without allowing widened branches to
// reject inputs that were accepted by exactly one canonical branch.
delete node.oneOf;
if (Array.isArray(node.anyOf)) {
const existingAllOf = Array.isArray(node.allOf) ? node.allOf : [];
node.allOf = [...existingAllOf, { anyOf: schemas }];
} else {
node.anyOf = schemas;
}
changed = true;
continue;
}
if (
branchesChanged &&
schemas.length > 0 &&
schemas.every(isBooleanSchema)
) {
node.type = "boolean";
preserveSharedDefault(node, schemas);
delete node[combinator];
changed = true;
continue;
}
node[combinator] = schemas;
changed ||= branchesChanged;
}
return { schema: node, changed };
};
// Anthropic tool input schemas reject anyOf, oneOf, and allOf at the root, and
// clients then drop the tool or advertise it without parameters. Present a
// root union of strict object groups as one object whose properties are the
@@ -215,7 +404,11 @@ export const presentInputJsonSchema = (
): Record<string, unknown> => {
const flattened = flattenRootObjectUnion(root);
const described = describeSchema(flattened, flattened, describeProperty);
if (!isObject(described)) return flattened;
if (!isObject(described))
return normalizeAdvertisedSchema(flattened).schema as Record<
string,
unknown
>;
const {
dependencies: _dependencies,
dependentRequired: _dependentRequired,
@@ -237,12 +430,19 @@ export const presentInputJsonSchema = (
]
: [],
);
if (dependencyGuidance.length === 0) return presentation;
if (dependencyGuidance.length === 0)
return normalizeAdvertisedSchema(presentation).schema as Record<
string,
unknown
>;
const description = [
typeof presentation.description === "string"
? presentation.description
: undefined,
...dependencyGuidance,
].filter((text) => text !== undefined);
return { ...presentation, description: description.join(" ") };
return normalizeAdvertisedSchema({
...presentation,
description: description.join(" "),
}).schema as Record<string, unknown>;
};
+190
View File
@@ -379,3 +379,193 @@ describe("root input presentation", () => {
});
});
});
describe("advertised Boolean literals", () => {
it("keeps canonical boolean-literal inputs accepted after advertisement", () => {
const canonical = {
type: "object",
properties: {
enabled: { type: "boolean", const: false },
},
required: ["enabled"],
additionalProperties: false,
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
const canonicalValidate = new Ajv2020({ strict: false }).compile(canonical);
const advertisedValidate = new Ajv2020({ strict: false }).compile(
advertised,
);
expect(canonicalValidate({ enabled: false })).toBe(true);
expect(canonicalValidate({ enabled: true })).toBe(false);
expect(advertisedValidate({ enabled: false })).toBe(true);
expect(advertisedValidate({ enabled: true })).toBe(true);
expect(advertised.properties).toEqual({ enabled: { type: "boolean" } });
});
it("drops affected negation and conditional groups without rejecting canonical inputs", () => {
const negated = {
type: "object",
properties: {
flag: { type: "boolean", not: { const: false } },
},
};
const negatedAdvertisement = presentInputJsonSchema(
negated,
() => undefined,
);
const canonicalNegated = new Ajv2020({ strict: false }).compile(negated);
const advertisedNegated = new Ajv2020({ strict: false }).compile(
negatedAdvertisement,
);
expect(canonicalNegated({ flag: true })).toBe(true);
expect(canonicalNegated({ flag: false })).toBe(false);
expect(advertisedNegated({ flag: true })).toBe(true);
const conditional = {
type: "object",
properties: {
choice: {
if: { const: false },
then: { type: "string" },
else: { type: "boolean" },
},
},
};
const conditionalAdvertisement = presentInputJsonSchema(
conditional,
() => undefined,
);
const canonicalConditional = new Ajv2020({ strict: false }).compile(
conditional,
);
const advertisedConditional = new Ajv2020({ strict: false }).compile(
conditionalAdvertisement,
);
expect(canonicalConditional({ choice: true })).toBe(true);
expect(advertisedConditional({ choice: true })).toBe(true);
expect(conditionalAdvertisement.properties).toEqual({
choice: {},
});
});
});
describe("advertised Boolean schema composition", () => {
it("widens an affected oneOf to anyOf when branches could overlap", () => {
const canonical = {
type: "object",
properties: {
flag: {
oneOf: [{ type: "boolean", const: false }, {}],
},
},
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
const canonicalValidate = new Ajv2020({ strict: false }).compile(canonical);
const advertisedValidate = new Ajv2020({ strict: false }).compile(
advertised,
);
expect(canonicalValidate({ flag: true })).toBe(true);
expect(canonicalValidate({ flag: false })).toBe(false);
expect(advertisedValidate({ flag: true })).toBe(true);
expect(advertised).toHaveProperty(["properties", "flag", "anyOf"]);
});
it("retains nested branch guidance when widening oneOf to anyOf", () => {
const canonical = {
type: "object",
properties: {
selection: {
oneOf: [
{
type: "object",
properties: {
source: { type: "string" },
mode: { type: "boolean", const: false },
},
required: ["source", "mode"],
additionalProperties: false,
},
{
type: "object",
properties: {
target: { type: "string" },
mode: { type: "boolean", const: true },
},
required: ["target", "mode"],
additionalProperties: false,
},
],
},
},
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
const canonicalValidate = new Ajv2020({ strict: false }).compile(canonical);
const advertisedValidate = new Ajv2020({ strict: false }).compile(
advertised,
);
const example = { selection: { source: "capture.json", mode: false } };
expect(canonicalValidate(example)).toBe(true);
expect(advertisedValidate(example)).toBe(true);
expect(advertised).toMatchObject({
properties: {
selection: {
anyOf: [
{ properties: { source: { type: "string" } } },
{ properties: { target: { type: "string" } } },
],
},
},
});
});
it("removes a nested not when widening a boolean item predicate", () => {
const canonical = {
type: "object",
properties: {
values: {
type: "array",
not: { items: { type: "boolean", const: false } },
},
},
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
const canonicalValidate = new Ajv2020({ strict: false }).compile(canonical);
const advertisedValidate = new Ajv2020({ strict: false }).compile(
advertised,
);
expect(canonicalValidate({ values: [true] })).toBe(true);
expect(advertisedValidate({ values: [true] })).toBe(true);
expect(advertised).toHaveProperty(["properties", "values"], {
type: "array",
});
});
it("normalizes schema keywords only and preserves literal payloads", () => {
const literal = {
const: false,
enum: [false],
properties: { nested: { const: true } },
};
const presented = presentInputJsonSchema(
{
type: "object",
properties: {
default: { type: "boolean", const: false },
allowed: { enum: [false, true] },
kind: { type: "string", const: "exact" },
},
default: literal,
examples: [literal],
},
() => undefined,
);
expect(presented.properties).toEqual({
default: { type: "boolean" },
allowed: { type: "boolean" },
kind: { type: "string", const: "exact" },
});
expect(presented.default).toEqual(literal);
expect(presented.examples).toEqual([literal]);
});
});
@@ -21,7 +21,10 @@ import { processScenarioSchema } from "../../../src/domain/process/processScenar
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
import { ToolResultDelivery } from "../../../src/server/toolResult.js";
import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js";
import { arrayItemSchemaError } from "../../fixtures/mcpClientSchemaConstraints.js";
import {
arrayItemSchemaError,
booleanLiteralSchemaError,
} from "../../fixtures/mcpClientSchemaConstraints.js";
interface AdvertisedTool {
readonly name: string;
@@ -225,6 +228,9 @@ describe("MCP compact schema constraints", () => {
);
for (const tool of tools) {
expect(arrayItemSchemaError(tool.inputSchema, tool.name)).toBeUndefined();
expect(
booleanLiteralSchemaError(tool.inputSchema, tool.name),
).toBeUndefined();
expect(
ajv.validateSchema(tool.inputSchema),
`${tool.name}: ${ajv.errorsText(ajv.errors)}`,
+62 -3
View File
@@ -9,7 +9,10 @@ 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";
import { arrayItemSchemaError } from "../../fixtures/mcpClientSchemaConstraints.js";
import {
arrayItemSchemaError,
booleanLiteralSchemaError,
} from "../../fixtures/mcpClientSchemaConstraints.js";
import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js";
interface ToolSchemas {
@@ -40,7 +43,15 @@ function schemaErrors(tools: readonly ToolSchemas[]): string[] {
schema,
`${tool.name}.${kind}`,
);
return clientError === undefined ? [] : [clientError];
if (clientError !== undefined) return [clientError];
if (kind === "inputSchema") {
const literalError = booleanLiteralSchemaError(
schema,
`${tool.name}.${kind}`,
);
if (literalError !== undefined) return [literalError];
}
return [];
} catch (cause: unknown) {
return [
`${tool.name}.${kind}: ${cause instanceof Error ? cause.message : String(cause)}`,
@@ -466,6 +477,35 @@ describe("MCP JSON Schema validity", () => {
expect(recursiveReferences(tools)).toEqual([]);
expectKnownAuthorityHints(tools);
const byName = new Map(tools.map((tool) => [tool.name, tool]));
for (const side of ["before", "after"])
expect(byName.get("compare_web_captures")?.inputSchema).toHaveProperty(
[
"properties",
side,
"properties",
"inspection",
"properties",
"scripts",
"properties",
"items",
"items",
"properties",
"source",
"anyOf",
],
expect.arrayContaining([
expect.objectContaining({
properties: expect.objectContaining({
reason: { type: "string" },
}),
}),
expect.objectContaining({
properties: expect.objectContaining({
artifact: expect.objectContaining({ type: "object" }),
}),
}),
]),
);
const catalogProjection = tools.map((tool) => ({
name: tool.name,
title: tool.title,
@@ -578,7 +618,11 @@ describe("MCP root input schemas", () => {
(name === "inspect_web_page" && flag === false);
const parsed = contract.inputSchema.safeParse(input);
expect(parsed.success, `${name}: ${String(flag)}`).toBe(accepted);
expect(validate(input), `${name}: ${String(flag)}`).toBe(accepted);
// The advertisement relaxes Boolean literals; the canonical parser
// still rejects false when this operation requires script sources.
expect(validate(input), `${name}: ${String(flag)}`).toBe(
flag === undefined || typeof flag === "boolean",
);
if (parsed.success && flag === undefined)
expect(parsed.data).toMatchObject({
include_script_sources: name === "analyze_web_bundle",
@@ -831,6 +875,21 @@ it("checks client array constraints in schema positions while preserving literal
).toBe(
"broken.properties.examples: tool parameters array type must have items",
);
const booleanMetadata = {
type: "object",
properties: { default: { type: "boolean", const: false } },
default: { const: true },
examples: [{ enum: [false] }],
};
expect(
booleanLiteralSchemaError(
{ ...booleanMetadata, properties: {} },
"literal",
),
).toBeUndefined();
expect(booleanLiteralSchemaError(booleanMetadata, "literal")).toBe(
"literal.properties.default.const is boolean",
);
});
it.each([false, true])(
+33
View File
@@ -36,3 +36,36 @@ export const arrayItemSchemaError = (
}
return undefined;
};
/** Reject Gemini-incompatible boolean literals in advertised input positions. */
export const booleanLiteralSchemaError = (
schema: unknown,
path: string,
): string | undefined => {
if (!isRecord(schema)) return undefined;
if (typeof schema.const === "boolean") return `${path}.const is boolean`;
if (
Array.isArray(schema.enum) &&
schema.enum.some((value) => typeof value === "boolean")
)
return `${path}.enum contains a boolean`;
for (const [key, value] of Object.entries(schema)) {
const children =
schemaMaps.has(key) && isRecord(value)
? Object.entries(value).map(
([name, child]) => [`${key}.${name}`, child] as const,
)
: schemaArrays.has(key) && Array.isArray(value)
? value.map(
(child: unknown, index) => [`${key}[${index}]`, child] as const,
)
: schemaChildren.has(key)
? [[key, value] as const]
: [];
for (const [suffix, child] of children) {
const error = booleanLiteralSchemaError(child, `${path}.${suffix}`);
if (error !== undefined) return error;
}
}
return undefined;
};