diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 38161e1bd0..60713e9b22 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -26,7 +26,16 @@ health transition leaves that catalog unchanged and does not emit Advertised input and output schemas contain no reachable recursive references. Schemas share repeated definitions through schema-local references while preserving complete nested fields and validation rules. Input properties retain -their descriptions and literal examples. Root object unions are presented as +their descriptions and literal examples. + +Hosts forward input schemas to the model with every request, so advertised +examples never copy complete producer Evidence. An embedded Evidence record is +shown as its exact `{"kind":"retained-evidence","evidence_id":"ev_..."}` +reference where the tool accepts one. An example that can only carry inline +Evidence stays in the canonical contract and its executable tests, but is not +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. diff --git a/src/contracts/advertisedInputExamples.test.ts b/src/contracts/advertisedInputExamples.test.ts new file mode 100644 index 0000000000..3f7b12745d --- /dev/null +++ b/src/contracts/advertisedInputExamples.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { advertisedInputExamples } from "./advertisedInputExamples.js"; +import { evidenceInputSchema } from "./evidenceInputContracts.js"; +import { JAVASCRIPT_FEATURE_TRACE_EXAMPLE } from "./javascript/javascriptApplicationWorkflowExamples.js"; +import { TOOL_CONTRACTS } from "./toolContracts.js"; +import { evidenceSchema } from "../domain/evidence.js"; +import type { JsonValue } from "../domain/jsonValue.js"; + +const evidence = JAVASCRIPT_FEATURE_TRACE_EXAMPLE.application; +const retained = { + kind: "retained-evidence", + evidence_id: evidence.evidence_id, +} as const; + +const embedsEvidence = (value: JsonValue): boolean => { + if (Array.isArray(value)) return value.some(embedsEvidence); + if (value === null || typeof value !== "object") return false; + return ( + evidenceSchema.safeParse(value).success || + Object.values(value).some(embedsEvidence) + ); +}; + +describe("advertised input examples", () => { + it("shows embedded Evidence as its exact retained reference", () => { + const inputSchema = z.strictObject({ + left: evidenceInputSchema, + right: z.array(evidenceInputSchema), + label: z.string(), + }); + expect( + advertisedInputExamples({ + inputSchema, + examples: [ + { + title: "Inline", + input: { left: evidence, right: [evidence], label: "a" }, + }, + { + title: "Retained", + input: { left: retained, right: [retained], label: "a" }, + }, + { title: "Other", input: { left: retained, right: [], label: "b" } }, + ], + }), + ).toEqual([ + { left: retained, right: [retained], label: "a" }, + { left: retained, right: [], label: "b" }, + ]); + }); + + it("leaves the advertisement when only inline Evidence is accepted", () => { + const inputSchema = z.strictObject({ + left: z.union([evidenceSchema, z.string()]), + }); + expect( + advertisedInputExamples({ + inputSchema, + examples: [ + { title: "Inline", input: { left: evidence } }, + { title: "Literal", input: { left: "value" } }, + ], + }), + ).toEqual([{ left: "value" }]); + }); + + it("advertises only distinct canonical inputs without complete Evidence", () => { + for (const contract of TOOL_CONTRACTS) { + const examples = advertisedInputExamples(contract); + const serialized = examples.map((example) => JSON.stringify(example)); + expect(new Set(serialized).size, contract.name).toBe(examples.length); + for (const example of examples) { + expect(contract.inputSchema.safeParse(example).success).toBe(true); + expect(embedsEvidence(example), contract.name).toBe(false); + } + } + }); +}); diff --git a/src/contracts/advertisedInputExamples.ts b/src/contracts/advertisedInputExamples.ts new file mode 100644 index 0000000000..e88832a69e --- /dev/null +++ b/src/contracts/advertisedInputExamples.ts @@ -0,0 +1,75 @@ +import type { ToolContract } from "./toolContractTypes.js"; +import { evidenceSchema } from "../domain/evidence.js"; +import type { JsonValue } from "../domain/jsonValue.js"; + +// Hosts forward every advertised input schema to the model on each turn. A +// canonical example that embeds complete producer Evidence repeats a prior +// result's bytes in that context while showing nothing about argument choice +// that the schema and descriptions do not already state; agents pass Evidence +// they received rather than composing it. + +interface RetainedProjection { + readonly value: JsonValue; + readonly embedsEvidence: boolean; +} + +/** The Evidence identifier of a complete Evidence record, or undefined. */ +const evidenceRecordId = (value: { + readonly [key: string]: JsonValue; +}): string | undefined => + typeof value.evidence_id === "string" && + Object.hasOwn(value, "normalized_result") && + evidenceSchema.safeParse(value).success + ? value.evidence_id + : undefined; + +/** Replace each embedded complete Evidence record with its exact retained reference. */ +const referenceEmbeddedEvidence = (value: JsonValue): RetainedProjection => { + if (Array.isArray(value)) { + const items = value.map(referenceEmbeddedEvidence); + return { + value: items.map((item) => item.value), + embedsEvidence: items.some((item) => item.embedsEvidence), + }; + } + if (value === null || typeof value !== "object") + return { value, embedsEvidence: false }; + const evidenceId = evidenceRecordId(value); + if (evidenceId !== undefined) + return { + value: { kind: "retained-evidence", evidence_id: evidenceId }, + embedsEvidence: true, + }; + const entries = Object.entries(value).map( + ([key, child]) => [key, referenceEmbeddedEvidence(child)] as const, + ); + return { + value: Object.fromEntries( + entries.map(([key, child]) => [key, child.value]), + ), + embedsEvidence: entries.some(([, child]) => child.embedsEvidence), + }; +}; + +/** + * Examples advertised in a tool's input schema. Embedded complete Evidence is + * shown as its exact same-session retained reference when the canonical input + * schema accepts that form; an example that can only carry inline Evidence + * stays in the canonical contract and its executable tests but leaves the + * advertisement. Every advertised example is a distinct valid canonical input. + */ +export const advertisedInputExamples = ( + contract: Pick, +): readonly JsonValue[] => { + const advertised = new Map(); + for (const { input } of contract.examples) { + const projection = referenceEmbeddedEvidence(input); + if ( + projection.embedsEvidence && + !contract.inputSchema.safeParse(projection.value).success + ) + continue; + advertised.set(JSON.stringify(projection.value), projection.value); + } + return [...advertised.values()]; +}; diff --git a/src/contracts/toolSchemaMetadata.ts b/src/contracts/toolSchemaMetadata.ts index 34089a929f..5ed63db5b4 100644 --- a/src/contracts/toolSchemaMetadata.ts +++ b/src/contracts/toolSchemaMetadata.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { ToolContract } from "./toolContractTypes.js"; +import { advertisedInputExamples } from "./advertisedInputExamples.js"; import { presentInputJsonSchema } from "./inputSchemaPresentation.js"; import { digestCanonicalValue } from "../domain/canonicalDigest.js"; @@ -63,9 +64,10 @@ export const toolInputSchemaWithMetadata = ( ...options, libraryOptions: { reused: "ref", ...options.libraryOptions }, }); + const examples = advertisedInputExamples(contract); return { ...presentInputJsonSchema(shared, fallbackPropertyDescription), - examples: contract.examples.map(({ input }) => input), + ...(examples.length === 0 ? {} : { examples }), }; }, ); diff --git a/tests/boundary/mcp/toolSchemaValidity.test.ts b/tests/boundary/mcp/toolSchemaValidity.test.ts index 993a5e00f5..1a27826d89 100644 --- a/tests/boundary/mcp/toolSchemaValidity.test.ts +++ b/tests/boundary/mcp/toolSchemaValidity.test.ts @@ -4,6 +4,7 @@ import { Ajv2020 } from "ajv/dist/2020.js"; import { describe, expect, it } from "vitest"; import { z } from "zod"; +import { advertisedInputExamples } from "../../../src/contracts/advertisedInputExamples.js"; import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js"; import { emptyArraySchema } from "../../../src/domain/emptyArraySchema.js"; import { processScenarioSchema } from "../../../src/domain/process/processScenario.js"; @@ -353,8 +354,9 @@ describe("MCP JSON Schema validity", () => { const tool = byName.get(contract.name); expect(tool?.title?.trim(), contract.name).toBeTruthy(); expect(tool?.description?.trim(), contract.name).toBeTruthy(); + const examples = advertisedInputExamples(contract); expect(tool?.inputSchema.examples, contract.name).toEqual( - contract.examples.map(({ input }) => input), + examples.length === 0 ? undefined : examples, ); for (const example of contract.examples) expect(