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 }) => {