From babfc87ec0895ebd75cf32d5cb85bb09d1d1a44e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 18:08:12 +0800 Subject: [PATCH] fix(mcp): present discover_tools input through the selected profile (#10592) * test(mcp): check advertised schemas against a portable dialect in every profile * fix(mcp): re-emit advertised patterns in a portable regex grammar * fix(mcp): present discover_tools input through the selected profile The hot-cold catalog registered discover_tools directly with the SDK, so the default compact input profile still advertised its canonical schema, including page-size defaults clients apply on their own (#5322). Route every listed input schema, transport discovery included, through one server-owned presentation, and pass the selected presentation to the deferred server, which serves recorded tool schemas but not discovery. * fix(mcp): validate advertised dialect by selected profile * fix(mcp): preserve schema widening through references * fix(mcp): refuse zero-padded repeat bounds and record host-specific path patterns * fix(mcp): keep references valid when presentation widens their targets * fix(mcp): relax references into negations and conditionals presentation may remove * fix(mcp): keep astral literals and dynamic references widening in every mode * fix(mcp): omit astral literals and relax untracked references when presentation widens * fix(mcp): read reference pointers by schema position, not segment spelling * fix(mcp): protect references into collapsing boolean unions and keep referencing branches out of the collapse * fix(mcp): omit patterns whose classes name astral characters * fix(mcp): decide union collapse and pointer protection from canonical branches * docs(mcp): document non-u astral matching and pin BMP agreement across V8 modes * test(mcp): expect the advertised base64 schema to omit the unbounded pad-bit probe * fix(mcp): protect references into unevaluated keywords lost evaluation removes * fix(mcp): emit stack-safe classes with non-ASCII members and protect draft-07 reference siblings * fix(mcp): protect references into literal siblings of relaxed draft-07 references * fix(mcp): keep the Evidence union on described borrowed inputs (#10597) * fix(mcp): keep the Evidence union on described borrowed inputs borrowEvidenceInput installed its advertised projection on one schema instance. A call site's .describe() rebuilds the schema from its definition, so match_native_functions advertised left and right as unconstrained values in every input profile. Install the projection in the borrowed parser's constructor so derived schemas keep it, for both borrowed inputs and borrowed arrays. * test(mcp): select the borrowed Evidence contract from its family * docs(mcp): admit baseline growth only for restored contract declarations --- docs/development/testing.md | 7 ++- src/contracts/advertisedDialectBaseline.ts | 34 +++++++------- src/domain/evidenceReference.ts | 41 ++++++++++++---- src/main/deferredRuntime.ts | 7 ++- src/server/DeferredEvidenceMcpServer.test.ts | 2 +- src/server/DeferredEvidenceMcpServer.ts | 14 ++++-- src/server/EvidenceMcpServerCore.ts | 47 +++++++++++-------- tests/boundary/mcp/advertisedDialect.test.ts | 29 ++++++++++++ tests/boundary/mcp/deferredRuntime.test.ts | 14 ++++++ .../mcp/evidenceInputContractsMcp.test.ts | 30 +++++++++++- 10 files changed, 173 insertions(+), 52 deletions(-) diff --git a/docs/development/testing.md b/docs/development/testing.md index ed776b2423..caa893b815 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -293,8 +293,11 @@ rendered declarations. `src/contracts/advertisedDialect.ts` owns the portable advertised dialect: the keyword allowlist, size and depth limits, and the regex grammar that compiles in V8, Python, RE2 and Rust. `advertisedDialect.test.ts` lists every input, output and catalog profile combination and compares the -violation counts with `advertisedDialectBaseline.ts`, which may only shrink; -the packaged catalog check applies the same rules to the default profiles. +violation counts with `advertisedDialectBaseline.ts`, which may only shrink, +except when a runtime analysis setting adds its `update_analysis_settings` +branch, or a fix restores a contract declaration the presentation had lost and +the PR names it; the packaged catalog check applies the same rules to the +default profiles. Advertised patterns, input and output, are enforced at zero: `portablePattern.ts` re-emits each one exactly, widens it, or omits it, and the presentation drops any enclosing `not` or `if` and turns `oneOf` into `anyOf` diff --git a/src/contracts/advertisedDialectBaseline.ts b/src/contracts/advertisedDialectBaseline.ts index 8d74d4b591..e760aa44cf 100644 --- a/src/contracts/advertisedDialectBaseline.ts +++ b/src/contracts/advertisedDialectBaseline.ts @@ -3,11 +3,14 @@ // definition the server can list (all catalog and output profiles). Counts may // only shrink: the catalog test requires an exact match, so removing a // construct lowers its count here, and any key not listed is enforced at zero. -// One exception grows them: each runtime analysis setting adds a branch to -// update_analysis_settings with two `const` members (`setting`, `action`). -// The full profile also counts the Evidence bundle's required -// `evidence_format` literal (src/domain/evidenceFormat.ts) in the inputs that -// carry a whole bundle. +// Two exceptions grow them. Each runtime analysis setting adds a branch to +// update_analysis_settings with two `const` members (`setting`, `action`), and +// the full profile also counts the Evidence bundle's required `evidence_format` +// literal (src/domain/evidenceFormat.ts) in the inputs that carry a whole +// bundle. A fix that restores a contract declaration the presentation had lost +// (for example a borrowed Evidence union advertised as an unconstrained value) +// counts its constructs again, and the PR names the restored declaration. A +// new contract or presentation construct never raises a count. // Every advertised pattern, input and output, is enforced at zero: the // presentation re-emits each one in the portable grammar. @@ -22,7 +25,6 @@ export const ADVERTISED_INPUT_DIALECT_BASELINE: Readonly< "input keyword $schema": 256, "input keyword allOf": 10, "input keyword const": 83, - "input keyword default": 2, "input keyword exclusiveMinimum": 61, "input keyword format": 20, "input keyword minProperties": 12, @@ -32,11 +34,11 @@ export const ADVERTISED_INPUT_DIALECT_BASELINE: Readonly< "input keyword uniqueItems": 2, "input type type-list": 11, "input type type:null": 14, - "input type untyped": 40, + "input type untyped": 38, }, full: { - "input any-of fewer-than-two-branches": 168, - "input any-of untyped-branch": 136, + "input any-of fewer-than-two-branches": 176, + "input any-of untyped-branch": 142, "input boolean-schema": 1, "input bound maxItems": 2, "input bound maxLength": 30, @@ -46,7 +48,7 @@ export const ADVERTISED_INPUT_DIALECT_BASELINE: Readonly< "input input-depth": 33, "input keyword $schema": 256, "input keyword allOf": 99, - "input keyword const": 1859, + "input keyword const": 1915, "input keyword default": 803, "input keyword else": 6, "input keyword examples": 253, @@ -55,13 +57,13 @@ export const ADVERTISED_INPUT_DIALECT_BASELINE: Readonly< "input keyword if": 7, "input keyword minProperties": 12, "input keyword not": 10, - "input keyword oneOf": 219, - "input keyword prefixItems": 255, - "input keyword propertyNames": 53, + "input keyword oneOf": 225, + "input keyword prefixItems": 267, + "input keyword propertyNames": 55, "input keyword then": 7, "input keyword uniqueItems": 5, - "input type type-list": 51, - "input type type:null": 372, - "input type untyped": 1454, + "input type type-list": 53, + "input type type:null": 386, + "input type untyped": 1500, }, }; diff --git a/src/domain/evidenceReference.ts b/src/domain/evidenceReference.ts index c4efba9230..6f031c75da 100644 --- a/src/domain/evidenceReference.ts +++ b/src/domain/evidenceReference.ts @@ -34,19 +34,44 @@ export const evidenceInputSchema = declareEvidenceInput( ); export type EvidenceInput = z.output; -/** Borrow a selected inline record until its resource and format owner admits it. */ -export const borrowEvidenceInput = () => { - const input = z.unknown().nonoptional(); - input._zod.processJSONSchema = evidenceInputSchema._zod.processJSONSchema; - return declareBorrowedEvidenceInput(input, evidenceInputSchema); +/** + * Advertise `declaration` for a borrowed parser. Zod rebuilds a derived schema, + * such as one returned by `.describe()`, from its definition through the + * instance constructor, so the projection belongs to that constructor rather + * than to one instance. + */ +const advertisedAs = ( + schema: Schema, + declaration: z.ZodType, +): Schema => { + const base = schema._zod.constr as unknown as { + init: (instance: Schema, definition: Schema["_zod"]["def"]) => void; + }; + const Borrowed = z.core.$constructor( + `BorrowedEvidence${schema._zod.def.type}`, + (instance, definition) => { + base.init(instance, definition); + instance._zod.processJSONSchema = (context, json, params) => + declaration._zod.processJSONSchema!(context, json, params); + }, + ); + return new Borrowed(schema._zod.def); }; +/** Borrow a selected inline record until its resource and format owner admits it. */ +export const borrowEvidenceInput = () => + declareBorrowedEvidenceInput( + advertisedAs(z.unknown().nonoptional(), evidenceInputSchema), + evidenceInputSchema, + ); + /** Borrow array width too; its service must admit capacity before resolution. */ export const borrowEvidenceArray = () => { const declaration = z.array(evidenceInputSchema); - const input = z.custom(Array.isArray); - input._zod.processJSONSchema = declaration._zod.processJSONSchema; - return declareBorrowedEvidenceInput(input, declaration); + return declareBorrowedEvidenceInput( + advertisedAs(z.custom(Array.isArray), declaration), + declaration, + ); }; /** Evidence input, or one record read from a local Evidence file or bundle. */ diff --git a/src/main/deferredRuntime.ts b/src/main/deferredRuntime.ts index e8c981ac4a..78ab0363b4 100644 --- a/src/main/deferredRuntime.ts +++ b/src/main/deferredRuntime.ts @@ -10,6 +10,7 @@ import { } from "../recordedMcpCatalog.js"; import type { AppConfig } from "../config/types.js"; import { + compactPresentationForMcpInputSchemaProfile, resolveMcpInputSchemaProfile, type McpInputSchemaProfile, } from "../config/mcpInputSchemaProfile.js"; @@ -215,7 +216,11 @@ export const runDeferred = async ( dependencies.setExitCode(1); throw cause; }), - config.mcpMaxInputElements, + { + maximumInputElements: config.mcpMaxInputElements, + compactPresentation: + compactPresentationForMcpInputSchemaProfile(profile), + }, ); if (config.mcpToolCatalogProfile === "hot-cold") server.enableProgressiveCatalog(); diff --git a/src/server/DeferredEvidenceMcpServer.test.ts b/src/server/DeferredEvidenceMcpServer.test.ts index 019411977d..c26b53c192 100644 --- a/src/server/DeferredEvidenceMcpServer.test.ts +++ b/src/server/DeferredEvidenceMcpServer.test.ts @@ -156,7 +156,7 @@ const fixture = async ( ); }); }, - options.maximumInputElements, + { maximumInputElements: options.maximumInputElements }, ); const client = new Client( { name: "deferred-fixture-client", version: "1" }, diff --git a/src/server/DeferredEvidenceMcpServer.ts b/src/server/DeferredEvidenceMcpServer.ts index 6356a58d70..bc9e325ec2 100644 --- a/src/server/DeferredEvidenceMcpServer.ts +++ b/src/server/DeferredEvidenceMcpServer.ts @@ -7,6 +7,7 @@ import type { } from "@modelcontextprotocol/server"; import type { PromptCompletionSource } from "./promptCompletion.js"; import type { RecordedMcpCatalog } from "../recordedMcpCatalog.js"; +import type { CompactInputSchemaPresentation } from "../contracts/compactInputSchema.js"; import type { ToolResultDelivery } from "./ToolResultDelivery.js"; import { EvidenceMcpServerCore, @@ -60,20 +61,25 @@ export class DeferredEvidenceMcpServer extends EvidenceMcpServerCore { private readonly initialize: ( server: DeferredEvidenceMcpServer, ) => Promise, - maximumInputElements?: number, + options: { + readonly maximumInputElements?: number | undefined; + // Recorded tools carry their presented schemas. The selected presentation + // still applies to transport discovery, which is not recorded. + readonly compactPresentation?: CompactInputSchemaPresentation | undefined; + } = {}, ) { super( catalog.info, { capabilities: catalog.capabilities, instructions: catalog.instructions, - ...(maximumInputElements === undefined + ...(options.maximumInputElements === undefined ? {} - : { maxToolInputElements: maximumInputElements }), + : { maxToolInputElements: options.maximumInputElements }), }, undefined, delivery, - undefined, + options.compactPresentation, // `main.ts` only defers the exact full advertisement catalog. "full", catalog.identity, diff --git a/src/server/EvidenceMcpServerCore.ts b/src/server/EvidenceMcpServerCore.ts index 91549e1fac..df494860b4 100644 --- a/src/server/EvidenceMcpServerCore.ts +++ b/src/server/EvidenceMcpServerCore.ts @@ -346,11 +346,19 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { this.progressiveCatalog = catalog; const { name, ...config } = DISCOVER_TOOLS; // Transport discovery has no target or analysis lifecycle. Domain tools - // continue through registerTool and their original named handlers. - super.registerTool(name, config, (input, context) => { - context.mcpReq.signal.throwIfAborted(); - return catalog.discover(input); - }); + // continue through registerTool and their original named handlers. Its + // advertisement still follows the selected input presentation. + super.registerTool( + name, + { + ...config, + inputSchema: this.presentInputSchema(config.inputSchema), + }, + (input, context) => { + context.mcpReq.signal.throwIfAborted(); + return catalog.discover(input); + }, + ); } override sendToolListChanged(): void { @@ -448,20 +456,7 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { ): RegisteredTool { if (canReferenceInlineEvidence(config.inputSchema)) this.registerEvidenceReferenceAlternative(name); - const budget = this.compactPresentation?.budgetBytes; - const inputSchema = - budget !== undefined && - config !== undefined && - hasAdvertisedInputJsonSchema(config.inputSchema) - ? transformAdvertisedInputJsonSchema( - config.inputSchema as Parameters< - typeof transformAdvertisedInputJsonSchema - >[0], - (projected) => - presentAdvertisedInputSchema(projected, { budgetBytes: budget }), - ) - : undefined; - const presentedSchema = inputSchema ?? config.inputSchema; + const presentedSchema = this.presentInputSchema(config.inputSchema); const registeredConfig = presentedSchema === undefined ? config @@ -514,6 +509,20 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { return registered; } + /** + * The input schema this server advertises for one registration: the + * selected compact presentation, or the registered schema unchanged. Every + * listed tool, including transport discovery, takes this one path. + */ + private presentInputSchema(schema: Schema): Schema { + const budget = this.compactPresentation?.budgetBytes; + if (budget === undefined || !hasAdvertisedInputJsonSchema(schema)) + return schema; + return transformAdvertisedInputJsonSchema(schema, (projected) => + presentAdvertisedInputSchema(projected, { budgetBytes: budget }), + ); + } + /** Whether this process advertises one named operation. */ advertisesTool(_name: string): boolean { return true; diff --git a/tests/boundary/mcp/advertisedDialect.test.ts b/tests/boundary/mcp/advertisedDialect.test.ts index b3e2dae1ca..e7c0473ccc 100644 --- a/tests/boundary/mcp/advertisedDialect.test.ts +++ b/tests/boundary/mcp/advertisedDialect.test.ts @@ -88,6 +88,35 @@ const listTools = async (environment: Readonly) => { return (await client.listTools()).tools; }; +it("presents transport discovery through the selected input profile", async () => { + const discovery = async (profile: "compact" | "full") => + ( + await listTools({ + ...process.env, + REA_MCP_INPUT_SCHEMA_PROFILE: profile, + REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold", + }) + ).find(({ name }) => name === "discover_tools")?.inputSchema; + const full = await discovery("full"); + // The canonical page defaults remain declared in the full profile only. + expect(full?.properties).toMatchObject({ + offset: { default: 0 }, + limit: { default: 3 }, + }); + const compact = await discovery("compact"); + expect(compact?.properties).toEqual({ + query: { type: "string" }, + names: { + type: "array", + items: { type: "string", minLength: 1 }, + minItems: 1, + maxItems: 20, + }, + offset: { type: "integer", minimum: 0, maximum: 9007199254740991 }, + limit: { type: "integer", minimum: 1, maximum: 20 }, + }); +}); + const distinct = ( into: Map, definition: AdvertisedToolDefinition, diff --git a/tests/boundary/mcp/deferredRuntime.test.ts b/tests/boundary/mcp/deferredRuntime.test.ts index 2025a6f4db..31c0034f76 100644 --- a/tests/boundary/mcp/deferredRuntime.test.ts +++ b/tests/boundary/mcp/deferredRuntime.test.ts @@ -110,6 +110,20 @@ it.each([ true, ); } + // Discovery is not recorded; it follows the default compact input profile. + expect( + initial.tools.find(({ name }) => name === "discover_tools")?.inputSchema + .properties, + ).toMatchObject({ + offset: { type: "integer", minimum: 0, maximum: 9007199254740991 }, + limit: { type: "integer", minimum: 1, maximum: 20 }, + }); + expect( + JSON.stringify( + initial.tools.find(({ name }) => name === "discover_tools") + ?.inputSchema, + ), + ).not.toContain('"default"'); const call = async (name: string, args: Record) => { if (environment.REA_MCP_OUTPUT_SCHEMA_PROFILE === "off") { const definition = (await client.listTools()).tools.find( diff --git a/tests/boundary/mcp/evidenceInputContractsMcp.test.ts b/tests/boundary/mcp/evidenceInputContractsMcp.test.ts index 26198d415a..4e41ba0125 100644 --- a/tests/boundary/mcp/evidenceInputContractsMcp.test.ts +++ b/tests/boundary/mcp/evidenceInputContractsMcp.test.ts @@ -4,8 +4,12 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { COMPACT_INPUT_SCHEMA_BUDGET_BYTES } from "../../../src/config/mcpInputSchemaProfile.js"; import { presentAdvertisedInputSchema } from "../../../src/contracts/compactInputSchema.js"; +import { SESSION_TOOL_CONTRACTS } from "../../../src/contracts/sessionToolContracts.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; -import { transformAdvertisedInputJsonSchema } from "../../../src/contracts/toolSchemaMetadata.js"; +import { + advertisedInputJsonSchema, + transformAdvertisedInputJsonSchema, +} from "../../../src/contracts/toolSchemaMetadata.js"; import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js"; import { advertisedSchemaErrors } from "../../fixtures/mcpAdvertisedSchemaValidity.js"; import { mcpTest } from "../../support/mcp/mcpFixture.js"; @@ -37,6 +41,30 @@ describe("Evidence input SDK boundaries", () => { expect(invalid.issues).toBeDefined(); }); + it("advertises the Evidence union for described borrowed inputs", () => { + // `.describe()` rebuilds a schema from its definition; the borrowed + // declaration must survive it rather than advertise an unconstrained value. + const contract = SESSION_TOOL_CONTRACTS.find( + (item) => item.name === "match_native_functions", + ); + if (contract === undefined) + throw new Error("Missing match_native_functions contract"); + const properties = advertisedInputJsonSchema(contract).properties as Record< + string, + Record + >; + for (const name of ["left", "right"]) { + const property = properties[name]; + expect(property?.description, name).toBe( + `${name === "left" ? "Left" : "Right"} list_procedures Evidence or exact retained reference`, + ); + const branches = property?.anyOf ?? property?.oneOf; + expect(Array.isArray(branches), name).toBe(true); + expect(JSON.stringify(branches)).toContain('"retained-evidence"'); + } + expect(properties.left_observations).toMatchObject({ type: "array" }); + }); + mcpTest.for(["full", "compact"] as const)( "advertises valid reference inputs after actual SDK conversion (%s)", async (profile, { mcp }) => {