From 27e140a232362c9d59d65e42fe19380dc10e1d06 Mon Sep 17 00:00:00 2001 From: Omisakin Joshua <117924167+thefolahan@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:12:37 +0100 Subject: [PATCH 1/3] fix(mcp): keep Gemini-rejected dependency keywords out of input schemas Gemini function declarations reject dependentRequired and dependencies, and one unknown keyword fails the client's whole tool catalog. Flattened root unions no longer advertise the partner map, and the SQLite input states its table requirement in its descriptions instead. The canonical parsers still enforce both rules; a boundary test now checks every advertised schema in the full and compact profiles. Fixes #2832 --- src/contracts/inputSchemaPresentation.ts | 23 ++------- src/contracts/toolInputReferences.test.ts | 28 ++++++---- src/domain/sqlite/sqliteDatabase.ts | 5 +- .../mcp/compactInputSchemaMcp.test.ts | 51 +++++++++++++++++++ 4 files changed, 75 insertions(+), 32 deletions(-) diff --git a/src/contracts/inputSchemaPresentation.ts b/src/contracts/inputSchemaPresentation.ts index 22ad0ae3081..ddad1b2e58c 100644 --- a/src/contracts/inputSchemaPresentation.ts +++ b/src/contracts/inputSchemaPresentation.ts @@ -172,19 +172,6 @@ const flattenRootObjectUnion = ( variants.set(name, known); } const required = sharedRequiredProperties(branches); - // A field unique to a group still needs that group's required partners. - // Counting properties alone would let optional fields pad an incomplete pair. - const dependencies = Object.fromEntries( - [...variants.keys()].flatMap((name) => { - const owners = branches.filter((branch) => - Object.hasOwn(branch.properties ?? {}, name), - ); - const partners = sharedRequiredProperties(owners).filter( - (partner) => partner !== name && !required.includes(partner), - ); - return partners.length > 0 ? [[name, partners]] : []; - }), - ); const fewestRequired = Math.min( ...branches.map((branch) => requiredProperties(branch).length), ); @@ -207,14 +194,10 @@ const flattenRootObjectUnion = ( ]), ), ...(required.length > 0 ? { required } : {}), + // Partner fields stay in the group description rather than in + // dependentRequired or dependencies: Gemini function declarations reject + // both, and one unknown keyword fails the client's whole tool catalog. ...(fewestRequired > 0 ? { minProperties: fewestRequired } : {}), - ...(Object.keys(dependencies).length > 0 - ? { - [root.$schema === "http://json-schema.org/draft-07/schema#" - ? "dependencies" - : "dependentRequired"]: dependencies, - } - : {}), ...(branches.every((branch) => branch.additionalProperties === false) ? { additionalProperties: false } : {}), diff --git a/src/contracts/toolInputReferences.test.ts b/src/contracts/toolInputReferences.test.ts index f75a06531e3..ac6fa00719d 100644 --- a/src/contracts/toolInputReferences.test.ts +++ b/src/contracts/toolInputReferences.test.ts @@ -339,21 +339,29 @@ describe("root input presentation", () => { { before: "first" }, { unexpected: true }, { before_scenario: 1, after_scenario: "wrong" }, + ]) { + expect(schema.safeParse(input).success).toBe(false); + expect(validate(input)).toBe(false); + } + // Gemini function declarations reject dependentRequired and + // dependencies, so partner fields are advertised only as guidance. + for (const keyword of ["dependentRequired", "dependencies"]) + expect(advertised).not.toHaveProperty(keyword); + // Cross-group exclusion and an optional field padding an incomplete + // group are enforced only by the canonical runtime parser. + for (const input of [ + { + before: "first", + after: "second", + before_scenario: 1, + after_scenario: 2, + }, { before_scenario: 1, normalization: true }, { after_scenario: 2, normalization: true }, ]) { + expect(validate(input)).toBe(true); expect(schema.safeParse(input).success).toBe(false); - expect(validate(input)).toBe(false); } - // Cross-group exclusion is enforced only by the canonical runtime parser. - const mixed = { - before: "first", - after: "second", - before_scenario: 1, - after_scenario: 2, - }; - expect(validate(mixed)).toBe(true); - expect(schema.safeParse(mixed).success).toBe(false); }, ); diff --git a/src/domain/sqlite/sqliteDatabase.ts b/src/domain/sqlite/sqliteDatabase.ts index 60f3c6d3fa3..dc0f2a3d899 100644 --- a/src/domain/sqlite/sqliteDatabase.ts +++ b/src/domain/sqlite/sqliteDatabase.ts @@ -52,7 +52,7 @@ export const inspectSqliteDatabaseInputSchema = z .min(1) .optional() .describe( - "Selected-table sample size, default 20; subject to byte and worker resource limits", + "Selected-table sample size, default 20; requires table. Subject to byte and worker resource limits", ), }) .superRefine((input, context) => { @@ -75,8 +75,9 @@ export const inspectSqliteDatabaseInputSchema = z message: "selected_table schema scope requires table", }); }) + // row_limit and columns state their table requirement in their descriptions: + // Gemini function declarations reject dependentRequired and fail every tool. .meta({ - dependentRequired: { row_limit: ["table"], columns: ["table"] }, if: { properties: { schema_scope: { const: "selected_table" } }, required: ["schema_scope"], diff --git a/tests/boundary/mcp/compactInputSchemaMcp.test.ts b/tests/boundary/mcp/compactInputSchemaMcp.test.ts index 5ca46c39b76..364d035258f 100644 --- a/tests/boundary/mcp/compactInputSchemaMcp.test.ts +++ b/tests/boundary/mcp/compactInputSchemaMcp.test.ts @@ -64,7 +64,58 @@ const fullServer = (): EvidenceMcpServer => new ToolResultDelivery(STDIO_DEFAULT_MAX_BUFFER_SIZE), ); +// Gemini function declarations reject these keywords, and one unknown keyword +// fails the client's whole tool catalog rather than the one tool (#2832). +const geminiRejectedKeywords = new Set(["dependentRequired", "dependencies"]); +const propertyNameMaps = new Set([ + "properties", + "patternProperties", + "$defs", + "definitions", +]); +const literalData = new Set(["const", "enum", "default", "examples"]); + +const geminiRejectedKeywordPaths = (value: unknown, path: string): string[] => { + if (Array.isArray(value)) + return value.flatMap((item, index) => + geminiRejectedKeywordPaths(item, `${path}/${index}`), + ); + if (typeof value !== "object" || value === null) return []; + return Object.entries(value).flatMap(([key, child]) => { + if (literalData.has(key)) return []; + if ( + propertyNameMaps.has(key) && + typeof child === "object" && + child !== null + ) + return Object.entries(child).flatMap(([name, schema]) => + geminiRejectedKeywordPaths(schema, `${path}/${key}/${name}`), + ); + return [ + ...(geminiRejectedKeywords.has(key) ? [`${path}/${key}`] : []), + ...geminiRejectedKeywordPaths(child, `${path}/${key}`), + ]; + }); +}; + describe("MCP compact input schema profile", () => { + it.each([ + ["full", fullServer], + ["compact", compactServer], + ] as const)( + "advertises no keyword Gemini rejects in the %s profile", + async (_profile, createServer) => { + const server = createServer(); + registerCatalog(server); + const tools = await listToolsThroughTransport(server); + expect( + tools.flatMap(({ name, inputSchema }) => + geminiRejectedKeywordPaths(inputSchema, name), + ), + ).toEqual([]); + }, + ); + it("advertises every tool input schema within the provider budget", async () => { const server = compactServer(); registerCatalog(server); From 5447a5d5ddfffe171c34ecce6641fdb5628d7290 Mon Sep 17 00:00:00 2001 From: Omisakin Joshua <117924167+thefolahan@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:07:46 +0100 Subject: [PATCH 2/3] fix(mcp): keep partner guidance in compact schemas and correct the contract The compact profile strips field descriptions, so the SQLite table requirement moved to the root description, which it keeps; a boundary assertion checks it in both profiles. docs/mcp-contracts.md no longer promises that advertised root unions express group dependencies: partner fields are guidance there and enforced by the canonical parser. --- docs/mcp-contracts.md | 12 ++++++++---- src/domain/sqlite/sqliteDatabase.ts | 7 +++++-- tests/boundary/mcp/compactInputSchemaMcp.test.ts | 7 +++++++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 876a30f6d31..d9aa5ce0b24 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -620,10 +620,14 @@ advertised. Every advertised example is a distinct valid canonical input. Root object unions are presented as one object without root `anyOf`, `oneOf`, or `allOf` for model API compatibility. -The projection merges properties, retains shared required fields and -group-specific field dependencies, and describes the accepted input groups. -Canonical runtime validation enforces the exact union, including exclusions -that the advertised projection does not express. Tests validate the schemas +The projection merges properties, retains shared required fields, and +describes the accepted input groups. Which fields belong together is guidance +in that description rather than a `dependentRequired` or `dependencies` +keyword, because Gemini function declarations reject both and one unknown +keyword fails the client's whole tool catalog. Canonical runtime validation +enforces the exact union, including partner fields and exclusions that the +advertised projection does not express, so a client must not rely on the +advertised schema to reject an incomplete group. Tests validate the schemas 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. diff --git a/src/domain/sqlite/sqliteDatabase.ts b/src/domain/sqlite/sqliteDatabase.ts index dc0f2a3d899..a8afeae8b11 100644 --- a/src/domain/sqlite/sqliteDatabase.ts +++ b/src/domain/sqlite/sqliteDatabase.ts @@ -75,9 +75,12 @@ export const inspectSqliteDatabaseInputSchema = z message: "selected_table schema scope requires table", }); }) - // row_limit and columns state their table requirement in their descriptions: - // Gemini function declarations reject dependentRequired and fail every tool. + // The table requirement is stated in the root description, which the compact + // profile keeps when it strips field descriptions: Gemini function + // declarations reject dependentRequired and fail every tool. .meta({ + description: + "columns and row_limit require table; schema_scope selected_table requires table.", if: { properties: { schema_scope: { const: "selected_table" } }, required: ["schema_scope"], diff --git a/tests/boundary/mcp/compactInputSchemaMcp.test.ts b/tests/boundary/mcp/compactInputSchemaMcp.test.ts index 364d035258f..96c759ffd24 100644 --- a/tests/boundary/mcp/compactInputSchemaMcp.test.ts +++ b/tests/boundary/mcp/compactInputSchemaMcp.test.ts @@ -113,6 +113,13 @@ describe("MCP compact input schema profile", () => { geminiRejectedKeywordPaths(inputSchema, name), ), ).toEqual([]); + // Partner fields are guidance the profile must keep once the keyword is gone. + const sqlite = tools.find( + ({ name }) => name === "inspect_sqlite_database", + ); + expect(sqlite?.inputSchema.description).toContain( + "columns and row_limit require table", + ); }, ); From ea4cfc9f11fb002923c999839df3bba6984d0b23 Mon Sep 17 00:00:00 2001 From: Omisakin Joshua <117924167+thefolahan@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:17:08 +0100 Subject: [PATCH 3/3] test(mcp): expect partner fields to be enforced by the canonical parser only --- docs/mcp-contracts.md | 7 ++--- .../mcp/browserComparisonInput.test.ts | 27 ++++++++++--------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index d9aa5ce0b24..9039962ec2d 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -695,9 +695,10 @@ change the SDK's catalog invalidation or availability checks. `normalization` defaults to `{ "rules": [] }`. The advertised schema includes complete nested capture fields and rejects -incomplete pairs and structurally malformed captures. Mixed comparison families -can pass advertised validation; the SDK rejects them against the canonical -input schema before invoking the handler. Domain validation additionally checks +incomplete pairs and structurally malformed captures. Mixed comparison families, +and `normalization` without both scenario captures, can pass advertised +validation; the SDK rejects them against the canonical input schema before +invoking the handler. Domain validation additionally checks relationships such as event sequence references and retained counts; JSON Schema does not express those cross-field invariants. diff --git a/tests/boundary/mcp/browserComparisonInput.test.ts b/tests/boundary/mcp/browserComparisonInput.test.ts index 0dba6faffb7..2fcf38858b2 100644 --- a/tests/boundary/mcp/browserComparisonInput.test.ts +++ b/tests/boundary/mcp/browserComparisonInput.test.ts @@ -110,15 +110,6 @@ describe("browser comparison input boundary", () => { }, { before_scenario: {}, after_scenario: {} }, { before_scenario: scenario.before_scenario }, - { ...passive, normalization: { rules: [] } }, - { - before_scenario: scenario.before_scenario, - normalization: { rules: [] }, - }, - { - after_scenario: scenario.after_scenario, - normalization: { rules: [] }, - }, { ...passive, unexpected: true }, { ...scenario, @@ -133,8 +124,20 @@ describe("browser comparison input boundary", () => { }, }, ]; - // The projection leaves group exclusion to the canonical runtime parser. - const mixedGroups = [{ ...passive, ...scenario }]; + // The projection leaves group exclusion and partner fields to the + // canonical runtime parser. + const runtimeOnlyInvalid = [ + { ...passive, ...scenario }, + { ...passive, normalization: { rules: [] } }, + { + before_scenario: scenario.before_scenario, + normalization: { rules: [] }, + }, + { + after_scenario: scenario.after_scenario, + normalization: { rules: [] }, + }, + ]; for (const { input, advertisedValid, runtimeValid } of [ ...[passive, scenario, withoutNormalization].map((input) => ({ input, @@ -146,7 +149,7 @@ describe("browser comparison input boundary", () => { advertisedValid: false, runtimeValid: false, })), - ...mixedGroups.map((input) => ({ + ...runtimeOnlyInvalid.map((input) => ({ input, advertisedValid: true, runtimeValid: false,