diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 4d3a7545b4..ca46e63f16 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -1248,11 +1248,22 @@ failures for such entries name this cause and the operation to rerun with Advertised input and output schemas contain no reachable recursive references. Input schemas inline repeated definitions before applying the selected presentation profile. Both `full` and `compact` start from this self-contained -projection; output schemas can share schema-local definitions. Full inputs -retain nested fields, descriptions and valid literal examples. The compatibility +projection; output schemas can share schema-local definitions. Analyst-authored +full inputs retain nested fields, descriptions and valid literal examples. The compatibility projections below can relax advertised constraints; canonical runtime validation still enforces the complete input contract. +`compare_web_captures` declares its complete producer observations as object +operands instead of repeating their full output schemas in the input catalog. +Pass the original `inspect_web_page` normalized result in `before.inspection` +and `after.inspection`, optional complete `discover_webmcp_tools` results in +`webmcp`, or complete `capture_browser_scenario` results in the scenario fields. +Do not construct partial observations from the abbreviated declaration: the +server still validates every canonical field before comparison. Input groups +and analyst-authored normalization rules remain explicit. This presentation +changes no capture bytes, identity, completeness, or comparison semantics and +does not reduce `inspect_analysis_view` selectors or other tools' declarations. + When a host forwards input schemas to the model, their examples consume context. REA's advertised examples therefore never copy complete producer Evidence. An embedded Evidence record is shown as its exact `{"kind":"retained-evidence","evidence_id":"ev_..."}` diff --git a/src/contracts/inputObservationPresentation.test.ts b/src/contracts/inputObservationPresentation.test.ts new file mode 100644 index 0000000000..b74dd19210 --- /dev/null +++ b/src/contracts/inputObservationPresentation.test.ts @@ -0,0 +1,70 @@ +import { Ajv } from "ajv"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect, it } from "vitest"; + +import { presentInputJsonSchema } from "./inputSchemaPresentation.js"; + +it.each(["draft-2020-12", "draft-07"] as const)( + "summarizes only declared observation schemas, preserving literal data in %s", + (target) => { + const literal = { + type: "object", + "x-rea-complete-observation": true, + properties: { producer_data: { type: "string" } }, + }; + const controls = { + type: "object", + properties: { match: { type: "string", minLength: 1 } }, + required: ["match"], + additionalProperties: false, + }; + const canonical = { + $schema: + target === "draft-07" + ? "http://json-schema.org/draft-07/schema#" + : "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + observation: { + type: "object", + description: "Complete result from the producer.", + "x-rea-complete-observation": true, + properties: { id: { type: "string" } }, + required: ["id"], + }, + controls, + literal: { const: literal }, + }, + required: ["observation", "controls", "literal"], + additionalProperties: false, + }; + const presented = presentInputJsonSchema(canonical, () => undefined); + expect(presented).toHaveProperty("properties.observation", { + type: "object", + description: expect.stringContaining("complete canonical observation"), + }); + expect(presented).toHaveProperty("properties.controls", controls); + expect(presented).toHaveProperty("properties.literal.const", literal); + const ajv = + target === "draft-07" + ? new Ajv({ strict: false }) + : new Ajv2020({ strict: false }); + const validate = ajv.compile(presented); + expect( + validate({ observation: {}, controls: { match: "x" }, literal }), + ).toBe(true); + expect( + validate({ observation: "{}", controls: { match: "x" }, literal }), + ).toBe(false); + expect( + validate({ observation: {}, controls: { match: "" }, literal }), + ).toBe(false); + expect( + ajv.compile(canonical)({ + observation: {}, + controls: { match: "x" }, + literal, + }), + ).toBe(false); + }, +); diff --git a/src/contracts/inputSchemaPresentation.ts b/src/contracts/inputSchemaPresentation.ts index dc40eefe02..01bfd83f4b 100644 --- a/src/contracts/inputSchemaPresentation.ts +++ b/src/contracts/inputSchemaPresentation.ts @@ -42,36 +42,47 @@ const describeSchema = ( references: JsonSchemaReferences, inherited: JsonSchemaScope | undefined, describeProperty: (property: string) => string | undefined, + propertyDescription?: string, ): unknown => { if (!isObject(schema)) return schema; + // These operands are complete producer results, not fields an agent builds. + // Their owner keeps full canonical validation; only their wire declaration + // omits the repeated result schema. Analyst-authored controls stay explicit. + if (schema.type === "object" && schema["x-rea-complete-observation"] === true) + return { + type: "object", + description: [ + propertyDescription ?? + (typeof schema.description === "string" + ? schema.description + : undefined), + "Pass the complete returned normalized_result unchanged. Its fields are omitted from this declaration; the server still validates the complete canonical observation.", + ] + .filter((text) => text !== undefined) + .join(" "), + }; const scope = references.enter(schema, inherited); - return Object.fromEntries( + const described = Object.fromEntries( Object.entries(schema).map(([key, value]) => { if (schemaMaps.has(key) && isObject(value)) return [ key, Object.fromEntries( Object.entries(value).map(([name, child]) => { - const described = describeSchema( - child, - references, - scope, - describeProperty, - ); - if ( - key !== "properties" || - !isObject(described) || - !isObject(child) - ) - return [name, described]; const description = - referencedDescription(references, child, scope) ?? - describeProperty(name); + key === "properties" && isObject(child) + ? (referencedDescription(references, child, scope) ?? + describeProperty(name)) + : undefined; return [ name, - description === undefined - ? described - : { ...described, description }, + describeSchema( + child, + references, + scope, + describeProperty, + description, + ), ]; }), ), @@ -96,6 +107,9 @@ const describeSchema = ( return [key, value]; }), ); + return propertyDescription === undefined + ? described + : { ...described, description: propertyDescription }; }; const objectBranch = ( diff --git a/src/contracts/toolSchemaMetadata.ts b/src/contracts/toolSchemaMetadata.ts index 5344f66c01..91044c0db0 100644 --- a/src/contracts/toolSchemaMetadata.ts +++ b/src/contracts/toolSchemaMetadata.ts @@ -85,7 +85,7 @@ export const toolInputSchemaWithMetadata = ( }, ); -/** Return the same complete input JSON Schema projection registered on MCP. */ +/** Return the full-profile advertised input JSON Schema registered on MCP. */ export const advertisedInputJsonSchema = ( contract: Contract, ): Record => diff --git a/src/domain/browserScenarioDiffValues.ts b/src/domain/browserScenarioDiffValues.ts index 67a1abdde2..a46166fdba 100644 --- a/src/domain/browserScenarioDiffValues.ts +++ b/src/domain/browserScenarioDiffValues.ts @@ -74,12 +74,14 @@ const normalizationInputSchema = z /** Input for step-aligned comparison of two browser scenario captures. */ export const compareBrowserScenariosInputSchema = z.strictObject({ - before_scenario: retainedBrowserScenarioCaptureSchema.describe( - "Earlier complete normalized_result from capture_browser_scenario.", - ), - after_scenario: retainedBrowserScenarioCaptureSchema.describe( - "Later complete normalized_result from capture_browser_scenario.", - ), + before_scenario: retainedBrowserScenarioCaptureSchema + .describe( + "Earlier complete normalized_result from capture_browser_scenario.", + ) + .meta({ "x-rea-complete-observation": true }), + after_scenario: retainedBrowserScenarioCaptureSchema + .describe("Later complete normalized_result from capture_browser_scenario.") + .meta({ "x-rea-complete-observation": true }), normalization: normalizationInputSchema .default({ rules: [] }) .describe("Exact-literal rules committed in the comparison result."), diff --git a/src/domain/webCaptureDiffSchemas.ts b/src/domain/webCaptureDiffSchemas.ts index 3dbafffdf5..025405f8a7 100644 --- a/src/domain/webCaptureDiffSchemas.ts +++ b/src/domain/webCaptureDiffSchemas.ts @@ -6,10 +6,13 @@ import { webMcpDiscoverySchema } from "./webMcpDiscovery.js"; /** One normalized passive page snapshot accepted by capture comparison. */ export const captureSnapshotSchema = z.object({ - inspection: webPageInspectionSchema.describe( - "Complete normalized_result from inspect_web_page, including capture identity, observations, completeness, and limitations.", - ), + inspection: webPageInspectionSchema + .describe( + "Complete normalized_result from inspect_web_page, including capture identity, observations, completeness, and limitations.", + ) + .meta({ "x-rea-complete-observation": true }), webmcp: webMcpDiscoverySchema + .meta({ "x-rea-complete-observation": true }) .nullable() .default(null) .describe( diff --git a/tests/boundary/mcp/browserComparisonInput.test.ts b/tests/boundary/mcp/browserComparisonInput.test.ts index 8350c1dea1..a3c20725f9 100644 --- a/tests/boundary/mcp/browserComparisonInput.test.ts +++ b/tests/boundary/mcp/browserComparisonInput.test.ts @@ -62,21 +62,23 @@ function invalidComparisonInputs( const scenarioCapture = browserScenarioCaptureSchema.parse( scenario.before_scenario, ); - return [ + const runtimeOnlyInvalid = [ + { before: { inspection: {} }, after: passive.after }, + { before: passive.before, after: { ...passive.after, webmcp: {} } }, + { before_scenario: {}, after_scenario: {} }, + { ...scenario, before_scenario: { ...scenarioCapture, steps: [{}] } }, + ]; + const advertisedInvalid = [ {}, { before: passive.before }, - { before: { inspection: {} }, after: passive.after }, + { before: { inspection: "{}" }, after: passive.after }, { before: passive.before, - after: { ...passive.after, webmcp: {} }, + after: { ...passive.after, webmcp: "{}" }, }, - { before_scenario: {}, after_scenario: {} }, + { before_scenario: "{}", after_scenario: scenarioCapture }, { before_scenario: scenario.before_scenario }, { ...passive, unexpected: true }, - { - ...scenario, - before_scenario: { ...scenarioCapture, steps: [{}] }, - }, { ...scenario, normalization: { @@ -86,6 +88,7 @@ function invalidComparisonInputs( }, }, ]; + return { advertisedInvalid, runtimeOnlyInvalid }; } describe("browser comparison input boundary", () => { @@ -118,6 +121,10 @@ describe("browser comparison input boundary", () => { ({ name }) => name === contract.name, ); if (advertised === undefined) throw new Error("Missing comparison tool"); + // Claude Desktop rejects tools above its 64 KiB per-input-schema limit. + expect( + Buffer.byteLength(JSON.stringify(advertised.inputSchema)), + ).toBeLessThanOrEqual(65_536); expect(advertised.inputSchema.type).toBe("object"); // Anthropic tool input schemas reject root combinators. expect(advertised.inputSchema).not.toHaveProperty("anyOf"); @@ -146,6 +153,9 @@ describe("browser comparison input boundary", () => { "before + after; before_scenario + after_scenario + [normalization]", ); const runtimeOnlyInvalid = [ + // Complete observations are copied from producer results, not authored + // from their declaration. Malformed payloads still fail before effects. + ...invalid.runtimeOnlyInvalid, { ...passive, ...scenario }, { ...passive, normalization: { rules: [] } }, { @@ -165,7 +175,7 @@ describe("browser comparison input boundary", () => { runtimeValid: true, }), ), - ...invalid.map((input) => ({ + ...invalid.advertisedInvalid.map((input) => ({ input, advertisedValid: false, runtimeValid: false, diff --git a/tests/boundary/mcp/toolSchemaValidity.test.ts b/tests/boundary/mcp/toolSchemaValidity.test.ts index ea3f81d79a..c7e8ccc386 100644 --- a/tests/boundary/mcp/toolSchemaValidity.test.ts +++ b/tests/boundary/mcp/toolSchemaValidity.test.ts @@ -582,34 +582,16 @@ describe("MCP JSON Schema validity", () => { expectKnownAuthorityHints(tools); const byName = new Map(tools.map((tool) => [tool.name, tool])); expectManagedLocalSignatureSchema(byName.get("inspect_managed_members")); + const comparison = byName.get("compare_web_captures")?.inputSchema; 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" }), - }), - }), - ]), + expect(comparison).toHaveProperty( + ["properties", side, "properties", "inspection"], + { + type: "object", + description: expect.stringContaining( + "complete canonical observation", + ), + }, ); const catalogProjection = tools.map((tool) => ({ name: tool.name,