diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 5a8b92de38..e2b0c5eac0 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -31,6 +31,10 @@ check the complete catalog after SDK conversion and its generated counterpart. This is REA's local compatibility profile; individual model APIs can impose additional limits. +Output schemas share repeated definitions through local references while +preserving their complete fields and validation rules. Canonical Zod validation +and the input compatibility profile continue to apply. + For passive `compare_web_captures` inputs, pass each complete `inspect_web_page` result in `before.inspection` or `after.inspection`, with an optional complete `discover_webmcp_tools` result in the matching `webmcp` field. diff --git a/src/contracts/toolOutputReferences.test.ts b/src/contracts/toolOutputReferences.test.ts new file mode 100644 index 0000000000..587393473d --- /dev/null +++ b/src/contracts/toolOutputReferences.test.ts @@ -0,0 +1,177 @@ +import { Ajv } from "ajv"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { inlineLocalJsonSchemaReferences } from "../../tests/fixtures/localJsonSchemaReferences.js"; +import { toolContract } from "./toolContracts.js"; +import { toolOutputSchemaWithMetadata } from "./toolSchemaMetadata.js"; + +it.each(["draft-2020-12", "draft-07"] as const)( + "preserves refined numeric bounds and strict repeated objects through %s references", + (target) => { + const integer = z.number().int().nonnegative(); + const range = z + .object({ + offset: integer.max(65535), + length: integer.min(1), + exclusive: integer.positive(), + }) + .strict(); + const canonical = z.object({ before: range, after: range }).strict(); + const advertised = toolOutputSchemaWithMetadata({ + ...toolContract("open_binary"), + outputSchema: canonical, + })["~standard"].jsonSchema.output({ target }); + const ajv = + target === "draft-2020-12" + ? new Ajv2020({ strict: false }) + : new Ajv({ strict: false }); + const validate = ajv.compile(advertised); + const valid = { + before: { offset: 0, length: 1, exclusive: 1 }, + after: { offset: 65535, length: 9007199254740991, exclusive: 1 }, + }; + const samples: readonly unknown[] = [ + valid, + ...[ + { offset: -1 }, + { offset: 65536 }, + { offset: 0.5 }, + { length: 0 }, + { length: 9007199254740992 }, + { exclusive: 0 }, + { exclusive: -1 }, + { length: null }, + { unexpected: true }, + ].map((change) => ({ ...valid, before: { ...valid.before, ...change } })), + { ...valid, before: { offset: 0, exclusive: 1 } }, + { ...valid, after: null }, + { ...valid, unexpected: true }, + ]; + expect(canonical.safeParse(valid).success).toBe(true); + for (const sample of samples) + expect(validate(sample)).toBe(canonical.safeParse(sample).success); + expect(inlineLocalJsonSchemaReferences(advertised)).toEqual( + inlineLocalJsonSchemaReferences( + canonical["~standard"].jsonSchema.output({ target }), + ), + ); + }, +); + +it("keeps schema data containing reference-shaped objects untouched", () => { + const literal = { $ref: "https://example.invalid/value" }; + const expanded = inlineLocalJsonSchemaReferences({ + $defs: { + "a/b": { + type: "object", + properties: { $defs: { const: literal } }, + examples: [literal], + }, + }, + $ref: "#/$defs/a~1b", + description: "Keep the caller-facing annotation", + }); + expect(expanded).toEqual({ + type: "object", + properties: { $defs: { const: literal } }, + examples: [literal], + description: "Keep the caller-facing annotation", + }); +}); + +it.each(["draft-2020-12", "draft-07"] as const)( + "preserves layered native-selector string patterns through %s references", + (target) => { + const selector = z + .string() + .min(1) + .regex(/^[^\0]*$/u) + .regex(/^[^\s[\]]+$/u); + const canonical = z.object({ before: selector, after: selector }).strict(); + const advertised = toolOutputSchemaWithMetadata({ + ...toolContract("open_binary"), + outputSchema: canonical, + })["~standard"].jsonSchema.output({ target }); + const ajv = + target === "draft-2020-12" + ? new Ajv2020({ strict: false }) + : new Ajv({ strict: false }); + const validate = ajv.compile(advertised); + for (const value of [ + "setFoo:", + "dataTaskWithRequest:completionHandler:", + "", + "set\0Foo:", + "bad selector:", + "bad[selector]:", + ]) { + const sample = { before: value, after: "setFoo:" }; + expect(validate(sample)).toBe(canonical.safeParse(sample).success); + } + expect(inlineLocalJsonSchemaReferences(advertised)).toEqual( + inlineLocalJsonSchemaReferences( + canonical["~standard"].jsonSchema.output({ target }), + ), + ); + }, +); + +it("rejects unresolved and cyclic references rather than concealing schema drift", () => { + expect(() => + inlineLocalJsonSchemaReferences({ $ref: "#/$defs/missing" }), + ).toThrow("Missing schema reference"); + expect(() => + inlineLocalJsonSchemaReferences({ + $defs: { loop: { $ref: "#/$defs/loop" } }, + $ref: "#/$defs/loop", + }), + ).toThrow("Recursive schema reference"); +}); + +it("intersects referenced numeric bounds and rejects conflicting validation keywords", () => { + expect( + inlineLocalJsonSchemaReferences({ + $defs: { bounded: { type: "number", minimum: 1, maximum: 10 } }, + $ref: "#/$defs/bounded", + minimum: 0, + maximum: 9, + }), + ).toEqual({ type: "number", minimum: 1, maximum: 9 }); + expect(() => + inlineLocalJsonSchemaReferences({ + $defs: { strict: { type: "object" } }, + $ref: "#/$defs/strict", + type: "string", + }), + ).toThrow("Conflicting schema reference sibling: type"); +}); + +it("preserves strict-object evaluation scope across references and singleton intersections", () => { + const strict = { + type: "object", + properties: { value: { type: "string" } }, + additionalProperties: false, + }; + const schemas = [ + { + $defs: { strict }, + $ref: "#/$defs/strict", + additionalProperties: false, + }, + { allOf: [strict], additionalProperties: false }, + ]; + for (const schema of schemas) { + const expanded = z + .record(z.string(), z.unknown()) + .parse(inlineLocalJsonSchemaReferences(schema)); + const ajv = new Ajv2020({ strict: false }); + const original = ajv.compile(schema); + const normalized = ajv.compile(expanded); + expect(original({})).toBe(true); + expect(original({ value: "kept" })).toBe(false); + for (const sample of [{}, { value: "kept" }, { unexpected: true }, null]) + expect(normalized(sample)).toBe(original(sample)); + } +}); diff --git a/src/contracts/toolSchemaMetadata.test.ts b/src/contracts/toolSchemaMetadata.test.ts index f5f6d491ea..391282bfea 100644 --- a/src/contracts/toolSchemaMetadata.test.ts +++ b/src/contracts/toolSchemaMetadata.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { inlineLocalJsonSchemaReferences } from "../../tests/fixtures/localJsonSchemaReferences.js"; import { TOOL_CONTRACTS, toolContract } from "./toolContracts.js"; import { toolInputSchemaWithMetadata, @@ -19,8 +20,13 @@ describe("advertised tool JSON Schema", () => { expect(advertisedInput.examples, contract.name).toEqual( contract.examples.map(({ input: example }) => example), ); - expect(advertisedOutput, contract.name).toEqual( - contract.outputSchema["~standard"].jsonSchema.output({ target }), + expect( + inlineLocalJsonSchemaReferences(advertisedOutput), + contract.name, + ).toEqual( + inlineLocalJsonSchemaReferences( + contract.outputSchema["~standard"].jsonSchema.output({ target }), + ), ); } }); @@ -34,4 +40,46 @@ describe("advertised tool JSON Schema", () => { expect(latest.$schema).toBe("https://json-schema.org/draft/2020-12/schema"); expect(draft07.$schema).toBe("http://json-schema.org/draft-07/schema#"); }); + + it("preserves complete output meaning across JSON Schema dialects", () => { + for (const dialect of ["draft-2020-12", "draft-07"] as const) + for (const contract of TOOL_CONTRACTS) { + const advertised = toolOutputSchemaWithMetadata(contract)[ + "~standard" + ].jsonSchema.output({ target: dialect }); + const canonical = contract.outputSchema["~standard"].jsonSchema.output({ + target: dialect, + }); + expect( + inlineLocalJsonSchemaReferences(advertised), + contract.name, + ).toEqual(inlineLocalJsonSchemaReferences(canonical)); + } + }); + + it("retains memoization and explicit caller projection options", () => { + const contract = toolContract("analyze_javascript_application"); + const output = + toolOutputSchemaWithMetadata(contract)["~standard"].jsonSchema; + const shared = output.output({ target }); + expect(shared.$defs).toBeDefined(); + expect(output.output({ target })).toBe(shared); + const options = { target, libraryOptions: { reused: "inline" } }; + const inline = output.output(options); + expect(inline).toEqual( + contract.outputSchema["~standard"].jsonSchema.output(options), + ); + expect(inline.$defs).toBeUndefined(); + expect(output.output({ target })).toBe(shared); + const draft07 = output.output({ target: "draft-07" }); + expect(draft07.$schema).toBe("http://json-schema.org/draft-07/schema#"); + expect(inlineLocalJsonSchemaReferences(draft07)).toEqual( + inlineLocalJsonSchemaReferences( + contract.outputSchema["~standard"].jsonSchema.output({ + target: "draft-07", + }), + ), + ); + expect(output.output({ target: "draft-07" })).toBe(draft07); + }); }); diff --git a/src/contracts/toolSchemaMetadata.ts b/src/contracts/toolSchemaMetadata.ts index bed9526ba0..3a845a9ba6 100644 --- a/src/contracts/toolSchemaMetadata.ts +++ b/src/contracts/toolSchemaMetadata.ts @@ -63,11 +63,19 @@ export const toolInputSchemaWithMetadata = ( }), ); -/** Advertise a canonical output schema without reconverting it per listing. */ +/** Share repeated output definitions without changing canonical validation. */ export const toolOutputSchemaWithMetadata = ( contract: Contract, ): Contract["outputSchema"] => - withAdvertisedJsonSchema(contract.outputSchema, "output"); + withAdvertisedJsonSchema( + contract.outputSchema, + "output", + (project) => (options) => + project({ + ...options, + libraryOptions: { reused: "ref", ...options.libraryOptions }, + }), + ); const withAdvertisedJsonSchema = ( canonical: Schema, diff --git a/tests/boundary/mcp/outputSchemaSemanticConstraints.test.ts b/tests/boundary/mcp/outputSchemaSemanticConstraints.test.ts index 84d87dfea3..c7dbb95eda 100644 --- a/tests/boundary/mcp/outputSchemaSemanticConstraints.test.ts +++ b/tests/boundary/mcp/outputSchemaSemanticConstraints.test.ts @@ -17,6 +17,7 @@ import { import { nativeUiResultSchema } from "../../../src/domain/native/nativeUiObservation.js"; import { processCaptureSchema } from "../../../src/domain/process/processCapture.js"; import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js"; +import { inlineLocalJsonSchemaReferences } from "../../fixtures/localJsonSchemaReferences.js"; const record = (value: unknown): Record => { if (typeof value !== "object" || value === null || Array.isArray(value)) @@ -66,7 +67,7 @@ it("advertises semantic output invariants and keeps producers aligned with Zod", : { $defs: outputSchema.$defs }), }); return { - result, + result: record(inlineLocalJsonSchemaReferences(standaloneResultSchema)), validate: new Ajv2020({ strict: false, validateFormats: false, diff --git a/tests/fixtures/localJsonSchemaReferences.ts b/tests/fixtures/localJsonSchemaReferences.ts new file mode 100644 index 0000000000..aa8efdf131 --- /dev/null +++ b/tests/fixtures/localJsonSchemaReferences.ts @@ -0,0 +1,170 @@ +const schemaMaps = new Set([ + "properties", + "patternProperties", + "dependentSchemas", +]); +const schemaArrays = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]); +const schemaChildren = new Set([ + "items", + "additionalItems", + "contains", + "additionalProperties", + "unevaluatedProperties", + "unevaluatedItems", + "propertyNames", + "not", + "if", + "then", + "else", +]); +const lowerBounds = new Set(["minimum", "exclusiveMinimum"]); +const upperBounds = new Set(["maximum", "exclusiveMaximum"]); +const scopedKeywords = new Set([ + "properties", + "patternProperties", + "additionalProperties", + "items", + "additionalItems", + "prefixItems", + "contains", + "unevaluatedProperties", + "unevaluatedItems", +]); + +const record = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Inline finite local references for representation-independent schema assertions. */ +export const inlineLocalJsonSchemaReferences = ( + root: Readonly>, +): unknown => { + const resolve = (reference: string): Record => { + if (!reference.startsWith("#/")) + throw new Error(`Expected a local schema reference: ${reference}`); + let current: unknown = root; + for (const encoded of reference.slice(2).split("/")) { + const key = encoded.replaceAll("~1", "/").replaceAll("~0", "~"); + if (!record(current) || !Object.hasOwn(current, key)) + throw new Error(`Missing schema reference: ${reference}`); + current = current[key]; + } + if (!record(current)) + throw new Error(`Expected a referenced schema object: ${reference}`); + return current; + }; + + const visit = (value: unknown, active: ReadonlySet): unknown => { + if (!record(value)) return value; + if (typeof value.$ref === "string") { + const { $ref: reference, ...siblings } = value; + if (active.has(reference)) + throw new Error(`Recursive schema reference: ${reference}`); + const referenced = resolve(reference); + // Keep declarations in their original evaluation scope. + if (Object.keys(siblings).some((key) => scopedKeywords.has(key))) + return { + allOf: [ + visit(referenced, new Set([...active, reference])), + visit(siblings, active), + ], + }; + const merged = { ...referenced, ...siblings }; + for (const [key, entry] of Object.entries(siblings)) { + const previous = referenced[key]; + if ( + previous === undefined || + JSON.stringify(previous) === JSON.stringify(entry) + ) + continue; + if (typeof previous === "number" && typeof entry === "number") { + if (lowerBounds.has(key)) { + merged[key] = Math.max(previous, entry); + continue; + } + if (upperBounds.has(key)) { + merged[key] = Math.min(previous, entry); + continue; + } + } + throw new Error(`Conflicting schema reference sibling: ${key}`); + } + return visit(merged, new Set([...active, reference])); + } + if (Array.isArray(value.allOf) && value.allOf.length === 1) { + const child = visit(value.allOf[0], active); + const { allOf: _allOf, ...siblings } = value; + if ( + record(child) && + !Object.keys(siblings).some((key) => scopedKeywords.has(key)) && + Object.entries(siblings).every( + ([key, entry]) => + child[key] === undefined || + JSON.stringify(child[key]) === JSON.stringify(entry) || + (typeof child[key] === "number" && + typeof entry === "number" && + (lowerBounds.has(key) || upperBounds.has(key))), + ) + ) { + const merged = { ...child, ...siblings }; + for (const [key, entry] of Object.entries(siblings)) { + const previous = child[key]; + if (typeof previous !== "number" || typeof entry !== "number") + continue; + if (lowerBounds.has(key)) merged[key] = Math.max(previous, entry); + else if (upperBounds.has(key)) + merged[key] = Math.min(previous, entry); + } + return visit(merged, active); + } + } + const normalized = { ...value }; + if ( + typeof normalized.pattern === "string" && + Array.isArray(normalized.allOf) && + normalized.allOf.some( + (child: unknown) => + record(child) && child.pattern === normalized.pattern, + ) + ) + delete normalized.pattern; + if ( + typeof normalized.minimum === "number" && + typeof normalized.exclusiveMinimum === "number" && + normalized.exclusiveMinimum >= normalized.minimum + ) + delete normalized.minimum; + if ( + typeof normalized.maximum === "number" && + typeof normalized.exclusiveMaximum === "number" && + normalized.exclusiveMaximum <= normalized.maximum + ) + delete normalized.maximum; + return Object.fromEntries( + Object.entries(normalized) + .filter(([key]) => key !== "$defs" && key !== "definitions") + .map(([key, child]) => { + if (schemaMaps.has(key) && record(child)) + return [ + key, + Object.fromEntries( + Object.entries(child).map(([name, schema]) => [ + name, + visit(schema, active), + ]), + ), + ]; + if (schemaArrays.has(key) && Array.isArray(child)) + return [key, child.map((schema) => visit(schema, active))]; + if (schemaChildren.has(key)) + return [ + key, + Array.isArray(child) + ? child.map((schema) => visit(schema, active)) + : visit(child, active), + ]; + return [key, child]; + }), + ); + }; + return visit(root, new Set()); +};