From 46cc5ce056f9cae77505751fde3ed3e38c6827f9 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:53:35 +0800 Subject: [PATCH] fix(contracts): resolve local schema references within their resource --- src/contracts/compactInputSchema.test.ts | 208 +++++++++++++++ src/contracts/compactInputSchema.ts | 149 +++++++---- src/contracts/inputSchemaPresentation.ts | 126 ++++++--- src/contracts/jsonSchemaReferences.ts | 249 ++++++++++++++++++ src/contracts/toolInputReferences.test.ts | 129 +++++++++ src/contracts/toolOutputReferences.test.ts | 30 +++ src/contracts/toolSchemaMetadata.ts | 73 ++--- .../mcp/compactInputSchemaMcp.test.ts | 74 ++++++ 8 files changed, 903 insertions(+), 135 deletions(-) create mode 100644 src/contracts/jsonSchemaReferences.ts diff --git a/src/contracts/compactInputSchema.test.ts b/src/contracts/compactInputSchema.test.ts index 4854524a84..b2bc919feb 100644 --- a/src/contracts/compactInputSchema.test.ts +++ b/src/contracts/compactInputSchema.test.ts @@ -1,3 +1,4 @@ +import { Ajv } from "ajv"; import { Ajv2020 } from "ajv/dist/2020.js"; import { describe, expect, it } from "vitest"; @@ -352,3 +353,210 @@ describe("compact input schema budget and reduction", () => { expect(serializedBytes(schema)).toBeLessThanOrEqual(13312); }); }); + +it.each([ + ["draft-2020-12", "https://example.test/nested"], + ["draft-2020-12", "nested.json"], + ["draft-07", "https://example.test/nested"], + ["draft-07", "nested.json"], +] as const)( + "keeps embedded resource references scoped when inlining twice in %s with %s", + (dialect, id) => { + const source = { + $schema: + dialect === "draft-07" + ? "http://json-schema.org/draft-07/schema#" + : "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/root.json", + type: "object", + definitions: { + target: { type: "string" }, + nested: { + $id: id, + type: "object", + definitions: { target: { type: "number" } }, + properties: { value: { $ref: "#/definitions/target" } }, + required: ["value"], + }, + }, + properties: { + before: { $ref: "#/definitions/nested" }, + after: { $ref: "#/definitions/nested" }, + }, + required: ["before", "after"], + }; + const compact = compactAdvertisedInputSchema(source, 13312); + expect(compact.presentation).toBe("inline"); + expect(JSON.stringify(compact.schema)).not.toContain('"$ref":'); + const createAjv = () => + dialect === "draft-07" + ? new Ajv({ strict: false }) + : new Ajv2020({ strict: false }); + const canonical = createAjv().compile(source); + const advertised = createAjv().compile(compact.schema); + for (const value of [7, "seven", null]) { + const input = { before: { value }, after: { value } }; + expect(advertised(input)).toBe(canonical(input)); + } + }, +); + +it("decodes URI fragments and JSON Pointer escapes through schema arrays", () => { + const source = { + type: "object", + definitions: { "a/b~": { prefixItems: [{ type: "number" }] } }, + properties: { value: { $ref: "#/definitions/a%7E1b%7E0/prefixItems/0" } }, + }; + const result = compactAdvertisedInputSchema(source, 13312); + expect(result.presentation).toBe("inline"); + const canonical = new Ajv2020({ strict: false }).compile(source); + const advertised = new Ajv2020({ strict: false }).compile(result.schema); + for (const value of [7, "seven", null]) + expect(advertised({ value })).toBe(canonical({ value })); +}); + +it.each(["$dynamicRef", "$recursiveRef"])( + "does not strip resource identities from a partially expanded %s closure", + (keyword) => { + const result = compactAdvertisedInputSchema( + { + type: "object", + definitions: { + nested: { + $id: "https://example.test/nested", + type: "object", + properties: { next: { [keyword]: "#" } }, + }, + }, + properties: { child: { $ref: "#/definitions/nested" } }, + }, + 13312, + ); + expect(result.presentation).toBe("reduced"); + expect(JSON.stringify(result.schema)).not.toContain('"$ref":'); + expect(result.schema.description).toContain("canonical"); + }, +); + +it.each(["", "#legacy", "https://example.test/root.json"])( + "does not invent a pointer resource for an aliased or ambiguous nested ID %j", + (id) => { + const result = compactAdvertisedInputSchema( + { + $id: "https://example.test/root.json", + type: "object", + definitions: { + target: { type: "string" }, + nested: { + $id: id, + type: "object", + definitions: { target: { type: "number" } }, + properties: { value: { $ref: "#/definitions/target" } }, + }, + }, + properties: { child: { $ref: "#/definitions/nested" } }, + }, + 13312, + ); + expect(result.presentation).toBe("reduced"); + expect(result.schema.properties).toEqual({ child: {} }); + }, +); + +it("uses explicit reduction for Draft 7 reference siblings with conflicting client semantics", () => { + const result = compactAdvertisedInputSchema( + { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + definitions: { value: { type: "number" } }, + properties: { value: { $ref: "#/definitions/value", maximum: 5 } }, + }, + 13312, + ); + expect(result.presentation).toBe("reduced"); + expect(result.schema.properties).toEqual({ value: { type: "number" } }); +}); + +it.each(["draft-2020-12", "draft-07"] as const)( + "removes embedded dialect declarations only after static expansion in %s", + (dialect) => { + const $schema = + dialect === "draft-07" + ? "http://json-schema.org/draft-07/schema#" + : "https://json-schema.org/draft/2020-12/schema"; + const shared = { $ref: "#/definitions/value" }; + const source = { + $schema, + $id: "https://example.test/root", + type: "object", + properties: Object.fromEntries( + ["string", "number"].map((type) => [ + type, + { + $schema, + $id: `${type}.json#`, + type: "object", + definitions: { value: { type } }, + properties: { value: shared }, + required: ["value"], + }, + ]), + ), + required: ["string", "number"], + }; + const result = compactAdvertisedInputSchema(source, 13312); + expect(result.presentation).toBe("inline"); + expect(result.schema.$schema).toBe($schema); + expect(JSON.stringify(result.schema).match(/"\$schema"/gu)).toHaveLength(1); + const createAjv = () => + dialect === "draft-07" + ? new Ajv({ strict: false }) + : new Ajv2020({ strict: false }); + const canonical = createAjv().compile(source); + const advertised = createAjv().compile(result.schema); + for (const value of [ + { string: { value: "a" }, number: { value: 7 } }, + { string: { value: 7 }, number: { value: "a" } }, + ]) + expect(advertised(value)).toBe(canonical(value)); + }, +); + +it.each(["child.json", "https://example.test/child.json"])( + "does not expand ambiguous sibling resources with ID %s", + ($id) => { + const result = compactAdvertisedInputSchema( + { + type: "object", + properties: Object.fromEntries( + ["string", "number"].map((type) => [ + type, + { + $id, + type: "object", + definitions: { value: { type } }, + properties: { value: { $ref: "#/definitions/value" } }, + }, + ]), + ), + }, + 13312, + ); + expect(result.presentation).toBe("reduced"); + expect(result.schema.properties).toEqual({ string: {}, number: {} }); + }, +); + +it("includes restored root identity in the compact byte budget", () => { + const result = compactAdvertisedInputSchema( + { + $id: `https://example.test/${"a".repeat(1024)}`, + type: "object", + properties: { value: { type: "number" } }, + }, + 512, + ); + expect(result.presentation).toBe("reduced"); + expect(serializedBytes(result.schema)).toBeLessThanOrEqual(512); + expect(result.schema.properties).toEqual({ value: { type: "number" } }); +}); diff --git a/src/contracts/compactInputSchema.ts b/src/contracts/compactInputSchema.ts index f376511729..1a11749feb 100644 --- a/src/contracts/compactInputSchema.ts +++ b/src/contracts/compactInputSchema.ts @@ -1,3 +1,7 @@ +import { + JsonSchemaReferences, + type JsonSchemaScope, +} from "./jsonSchemaReferences.js"; import { schemaArrays, schemaChildren, @@ -124,66 +128,104 @@ const stripChildAnnotations = (child: unknown): unknown => { return kept; }; -const resolveLocalReference = ( - root: Record, - reference: string, -): unknown => { - if (reference === "#") return root; - if (!reference.startsWith("#/")) return undefined; - let value: unknown = root; - for (const token of reference.slice(2).split("/")) { - const key = token.replaceAll("~1", "/").replaceAll("~0", "~"); - if (!isSchemaObject(value) || !Object.hasOwn(value, key)) return undefined; - value = value[key]; - } - return value; -}; - /** Stop serialization at the budget before allocating an expanded schema. */ const inlineWithinBudget = ( root: Record, budgetBytes: number, ): Record | undefined => { + const references = new JsonSchemaReferences(root); + const identity = Object.fromEntries( + ["$id", "$anchor", "$schema"].flatMap((key) => + Object.hasOwn(root, key) ? [[key, root[key]]] : [], + ), + ); + const identityBytes = + Object.keys(identity).length === 0 ? 0 : serializedBytes(identity) - 2; + if (identityBytes > budgetBytes) return undefined; let resolved = true; function* serialize( value: unknown, position: "schema" | "map" | "data", - active: ReadonlySet, + scope: JsonSchemaScope | undefined, + active: ReadonlyMap>, ): Generator { if (Array.isArray(value)) { yield "["; for (const [index, child] of value.entries()) { if (index > 0) yield ","; - yield* serialize(child, position, active); + yield* serialize(child, position, scope, active); } yield "]"; } else if (isSchemaObject(value)) { + const currentScope = + position === "schema" ? references.enter(value, scope) : scope; + if ( + position === "schema" && + (currentScope === undefined || + currentScope.dialect !== references.rootScope.dialect || + [ + "$dynamicRef", + "$recursiveRef", + "$dynamicAnchor", + "$recursiveAnchor", + ].some((key) => Object.hasOwn(value, key))) + ) { + resolved = false; + return; + } if (position === "schema" && Object.keys(value).length === 0) { yield JSON.stringify(jsonValueSchema()); return; } if (position === "schema" && typeof value.$ref === "string") { - const target = resolveLocalReference(root, value.$ref); - if (target === undefined || active.has(value.$ref)) { + const target = references.resolve(currentScope, value.$ref); + if ( + target.kind !== "target" || + currentScope === undefined || + active.get(currentScope)?.has(value.$ref) + ) { resolved = false; return; } - const { $ref, ...siblings } = value; - // Reference siblings are independent constraints in draft 2020-12. - // Merging their keys into the target would overwrite validation rules. - yield* serialize( - Object.keys(siblings).length === 0 - ? target - : { allOf: [target, siblings] }, - "schema", - new Set([...active, $ref]), + const { + $ref, + $id: _id, + $anchor: _anchor, + $schema: _schema, + ...siblings + } = value; + const nextActive = new Map(active); + nextActive.set( + currentScope, + new Set([...(active.get(currentScope) ?? []), $ref]), ); + // Draft 7 ignores siblings, while clients such as Ajv also apply them. + // Do not label either interpretation an equivalent inline advertisement. + if ( + currentScope.dialect === "draft-07" && + Object.keys(siblings).length > 0 + ) { + resolved = false; + return; + } + const expanded = + Object.keys(siblings).length === 0 + ? target.value + : { allOf: [target.value, siblings] }; + yield* serialize(expanded, "schema", currentScope, nextActive); return; } yield "{"; let first = true; for (const [key, child] of Object.entries(value)) { - if (position === "schema" && definitionKeys.has(key)) continue; + if ( + position === "schema" && + (definitionKeys.has(key) || + key === "$id" || + key === "$anchor" || + key === "$schema") + ) + continue; if (!first) yield ","; first = false; yield JSON.stringify(key) + ":"; @@ -196,7 +238,7 @@ const inlineWithinBudget = ( (schemaArrays.has(key) || schemaChildren.has(key)) ? "schema" : "data"; - yield* serialize(child, childPosition, active); + yield* serialize(child, childPosition, currentScope, active); } yield "}"; } else { @@ -204,31 +246,47 @@ const inlineWithinBudget = ( } } const chunks: string[] = []; - let bytes = 0; - for (const chunk of serialize(root, "schema", new Set())) { + let bytes = identityBytes; + for (const chunk of serialize( + root, + "schema", + references.rootScope, + new Map(), + )) { bytes += Buffer.byteLength(chunk, "utf8"); if (bytes > budgetBytes || !resolved) return undefined; chunks.push(chunk); } - return resolved - ? (JSON.parse(chunks.join("")) as Record) - : undefined; + if (!resolved) return undefined; + const expanded: unknown = JSON.parse(chunks.join("")); + if (!isSchemaObject(expanded)) return undefined; + // Every emitted dependency is now static and reference-free. Nested resource + // identifiers/anchors can leave without duplicating identities at call sites. + const schema = { ...identity, ...expanded }; + return serializedBytes(schema) <= budgetBytes ? schema : undefined; }; /** Preserve every possible type, including referenced and nullable unions. */ const detectedTypes = ( schema: unknown, - root: Record, - active: ReadonlySet = new Set(), + references: JsonSchemaReferences, + inherited: JsonSchemaScope | undefined, + active: ReadonlySet = new Set(), ): readonly string[] | undefined => { if (!isSchemaObject(schema)) return undefined; + const scope = references.enter(schema, inherited); + if (scope === undefined) return undefined; if (typeof schema.$ref === "string") { - if (active.has(schema.$ref)) return undefined; - return detectedTypes( - resolveLocalReference(root, schema.$ref), - root, - new Set([...active, schema.$ref]), - ); + if (active.has(schema)) return undefined; + const target = references.resolve(scope, schema.$ref); + return target.kind === "target" + ? detectedTypes( + target.value, + references, + scope, + new Set([...active, schema]), + ) + : undefined; } if (typeof schema.type === "string") return [schema.type]; if ( @@ -243,7 +301,7 @@ const detectedTypes = ( : []; if (alternatives.length === 0) return undefined; const types = alternatives.map((branch) => - detectedTypes(branch, root, active), + detectedTypes(branch, references, scope, active), ); if (types.some((type) => type === undefined)) return undefined; return [...new Set(types.flatMap((type) => type ?? []))]; @@ -281,9 +339,10 @@ const reduceToShallowProperties = ( ] .filter((text) => text !== undefined) .join(" "); + const references = new JsonSchemaReferences(schema); const shallow = Object.fromEntries( Object.entries(properties).map(([name, value]) => { - const types = detectedTypes(value, schema); + const types = detectedTypes(value, references, references.rootScope); const type = types?.length === 1 ? types[0] : types; const description = isSchemaObject(value) && typeof value.description === "string" diff --git a/src/contracts/inputSchemaPresentation.ts b/src/contracts/inputSchemaPresentation.ts index 61868b0448..f6f66b4b61 100644 --- a/src/contracts/inputSchemaPresentation.ts +++ b/src/contracts/inputSchemaPresentation.ts @@ -1,3 +1,7 @@ +import { + JsonSchemaReferences, + type JsonSchemaScope, +} from "./jsonSchemaReferences.js"; import { isDeepStrictEqual } from "node:util"; import { @@ -9,43 +13,38 @@ import { const isObject = (value: unknown): value is Readonly> => typeof value === "object" && value !== null && !Array.isArray(value); -const resolveReference = ( - root: Readonly>, - reference: string, -): unknown => { - if (reference === "#") return root; - if (!reference.startsWith("#/")) return undefined; - let value: unknown = root; - for (const token of reference.slice(2).split("/")) { - const key = token.replaceAll("~1", "/").replaceAll("~0", "~"); - if (!isObject(value) || !Object.hasOwn(value, key)) return undefined; - value = value[key]; - } - return value; -}; - const referencedDescription = ( - root: Readonly>, + references: JsonSchemaReferences, schema: Readonly>, + inherited: JsonSchemaScope | undefined, ): string | undefined => { let value: unknown = schema; - const active = new Set(); + let scope = inherited; + const active = new Set(); while (isObject(value)) { - if (typeof value.description === "string") return value.description; - if (typeof value.$ref !== "string" || active.has(value.$ref)) - return undefined; - active.add(value.$ref); - value = resolveReference(root, value.$ref); + scope = references.enter(value, scope); + if ( + typeof value.description === "string" && + (typeof value.$ref !== "string" || scope?.dialect !== "draft-07") + ) + return value.description; + if (typeof value.$ref !== "string" || active.has(value)) return undefined; + active.add(value); + const target = references.resolve(scope, value.$ref); + if (target.kind !== "target") return undefined; + value = target.value; } return undefined; }; const describeSchema = ( schema: unknown, - root: Readonly>, + references: JsonSchemaReferences, + inherited: JsonSchemaScope | undefined, describeProperty: (property: string) => string | undefined, ): unknown => { if (!isObject(schema)) return schema; + const scope = references.enter(schema, inherited); return Object.fromEntries( Object.entries(schema).map(([key, value]) => { if (schemaMaps.has(key) && isObject(value)) @@ -53,11 +52,20 @@ const describeSchema = ( key, Object.fromEntries( Object.entries(value).map(([name, child]) => { - const described = describeSchema(child, root, describeProperty); - if (key !== "properties" || !isObject(described)) + const described = describeSchema( + child, + references, + scope, + describeProperty, + ); + if ( + key !== "properties" || + !isObject(described) || + !isObject(child) + ) return [name, described]; const description = - referencedDescription(root, described) ?? + referencedDescription(references, child, scope) ?? describeProperty(name); return [ name, @@ -71,16 +79,18 @@ const describeSchema = ( if (schemaArrays.has(key) && Array.isArray(value)) return [ key, - value.map((child) => describeSchema(child, root, describeProperty)), + value.map((child) => + describeSchema(child, references, scope, describeProperty), + ), ]; if (schemaChildren.has(key)) return [ key, Array.isArray(value) ? value.map((child) => - describeSchema(child, root, describeProperty), + describeSchema(child, references, scope, describeProperty), ) - : describeSchema(value, root, describeProperty), + : describeSchema(value, references, scope, describeProperty), ]; // Annotation values and literal const/enum data are not schemas. return [key, value]; @@ -89,18 +99,29 @@ const describeSchema = ( }; const objectBranch = ( - root: Readonly>, + references: JsonSchemaReferences, branch: unknown, ): Readonly> | undefined => { // Reference siblings evaluate separately from the target in 2020-12, so a // merged object could accept inputs the branch rejects. - const resolved = - isObject(branch) && typeof branch.$ref === "string" - ? Object.keys(branch).length === 1 - ? resolveReference(root, branch.$ref) - : undefined - : branch; + let resolved = branch; + if (isObject(branch) && typeof branch.$ref === "string") { + if (Object.keys(branch).length !== 1) return undefined; + const target = references.resolve(references.rootScope, branch.$ref); + if (target.kind !== "target") return undefined; + resolved = target.value; + } return isObject(resolved) && + references.enter(resolved, references.rootScope) === references.rootScope && + ![ + "$id", + "$schema", + "$anchor", + "$dynamicAnchor", + "$recursiveAnchor", + "$dynamicRef", + "$recursiveRef", + ].some((key) => Object.hasOwn(resolved, key)) && resolved.type === "object" && isObject(resolved.properties) ? resolved @@ -324,6 +345,7 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => { // union of the groups. The canonical Zod schema still enforces the exact group. const flattenRootObjectUnion = ( root: Record, + references: JsonSchemaReferences, ): Record => { const { anyOf, oneOf, ...rest } = root; const alternatives = Array.isArray(anyOf) ? anyOf : oneOf; @@ -333,7 +355,21 @@ const flattenRootObjectUnion = ( root.properties !== undefined ) return root; - const branches = alternatives.map((branch) => objectBranch(root, branch)); + // Lifting removes union locations and branch identities. References into those + // locations, named anchors or unresolved bases cannot survive that rewrite. + for (const { scope, target } of references.references()) + if ( + (target.kind === "unresolved" && target.reason === "dynamic") || + (scope === references.rootScope && + (target.kind !== "target" || + target.path.length === 0 || + target.path[0] === "anyOf" || + target.path[0] === "oneOf")) + ) + return root; + const branches = alternatives.map((branch) => + objectBranch(references, branch), + ); if (!branches.every((branch) => branch !== undefined)) return root; const variants = new Map(); for (const branch of branches) @@ -385,15 +421,17 @@ const presentDiscriminatedRoot = ( root: Record, ): Record => { const { "x-rea-root-discriminator": discriminator, ...canonical } = root; - const flattened = flattenRootObjectUnion(canonical); - if (typeof discriminator !== "string") return flattened; + const references = new JsonSchemaReferences(canonical); + const flattened = flattenRootObjectUnion(canonical, references); + if (typeof discriminator !== "string" || flattened === canonical) + return flattened; const alternatives = canonical.anyOf ?? canonical.oneOf; if (!Array.isArray(alternatives) || alternatives.length !== 2) throw new TypeError( "A presented root discriminator requires two object alternatives", ); const branches = alternatives.map((alternative) => { - const branch = objectBranch(canonical, alternative); + const branch = objectBranch(references, alternative); if (branch === undefined || branch.additionalProperties !== false) throw new TypeError( "A presented root discriminator requires strict objects", @@ -487,7 +525,13 @@ export const presentInputJsonSchema = ( describeProperty: (property: string) => string | undefined, ): Record => { const flattened = presentDiscriminatedRoot(root); - const described = describeSchema(flattened, flattened, describeProperty); + const references = new JsonSchemaReferences(flattened); + const described = describeSchema( + flattened, + references, + references.rootScope, + describeProperty, + ); if (!isObject(described)) return normalizeAdvertisedSchema(flattened).schema as Record< string, diff --git a/src/contracts/jsonSchemaReferences.ts b/src/contracts/jsonSchemaReferences.ts new file mode 100644 index 0000000000..b72267e387 --- /dev/null +++ b/src/contracts/jsonSchemaReferences.ts @@ -0,0 +1,249 @@ +import { + schemaArrays, + schemaChildren, + schemaMaps, +} from "./jsonSchemaKeywords.js"; + +type SchemaObject = Readonly>; +type Dialect = "draft-07" | "draft-2020-12"; + +/** A fragment's resource and URI base are distinct facts. */ +export interface JsonSchemaScope { + readonly root: SchemaObject; + readonly baseUri: string | undefined; + readonly dialect: Dialect | undefined; + readonly parent?: JsonSchemaScope; +} + +type LocalReference = + | { + readonly kind: "target"; + readonly value: unknown; + readonly path: readonly string[]; + } + | { readonly kind: "anchor" } + | { + readonly kind: "unresolved"; + readonly reason: + | "scope" + | "external" + | "fragment" + | "pointer" + | "resource-boundary" + | "dynamic"; + }; + +const isObject = (value: unknown): value is SchemaObject => + value !== null && typeof value === "object" && !Array.isArray(value); + +const dialectOf = ( + schema: SchemaObject, + inherited: Dialect | undefined, +): Dialect | undefined => + schema.$schema === undefined + ? inherited + : schema.$schema === "http://json-schema.org/draft-07/schema#" || + schema.$schema === "http://json-schema.org/draft-07/schema" + ? "draft-07" + : schema.$schema === "https://json-schema.org/draft/2020-12/schema" + ? "draft-2020-12" + : undefined; + +/** Per-projection scope ownership; no external resource loading or dynamic-reference evaluation. */ +export class JsonSchemaReferences { + readonly rootScope: JsonSchemaScope; + readonly #scopes = new WeakMap< + object, + WeakMap + >(); + readonly #referenceSites: { + scope: JsonSchemaScope; + reference: string; + keyword: string; + }[] = []; + readonly #identities = new Map(); + readonly #relativeIdentities = new WeakMap< + JsonSchemaScope, + Map + >(); + readonly #ambiguous = new WeakSet(); + + constructor(root: SchemaObject) { + let baseUri: string | undefined; + if (typeof root.$id === "string" && URL.canParse(root.$id)) { + const url = new URL(root.$id); + url.hash = ""; + baseUri = url.href; + } + this.rootScope = { + root, + baseUri, + dialect: dialectOf(root, "draft-2020-12"), + }; + if (baseUri !== undefined) this.#identities.set(baseUri, this.rootScope); + // Index resources before consumers resolve anything, so later duplicate IDs + // cannot invalidate facts that have already escaped from the owner. + const pending = [{ value: root as unknown, scope: this.rootScope }]; + const visited = new WeakMap>(); + while (pending.length > 0) { + const { value, scope: parent } = pending.pop()!; + if (!isObject(value)) continue; + const scope = this.#scope(value, parent); + if (scope === undefined) continue; + let seen = visited.get(scope); + if (seen === undefined) { + seen = new WeakSet(); + visited.set(scope, seen); + } + if (seen.has(value)) continue; + seen.add(value); + for (const keyword of ["$ref", "$dynamicRef", "$recursiveRef"]) { + const reference = value[keyword]; + if (typeof reference !== "string") continue; + this.#referenceSites.push({ scope, reference, keyword }); + if (keyword !== "$ref") continue; + const target = this.resolve(scope, reference); + if (target.kind === "target") + pending.push({ value: target.value, scope }); + } + for (const [key, child] of Object.entries(value)) { + if (schemaMaps.has(key) && isObject(child)) { + for (const item of Object.values(child)) + pending.push({ value: item, scope }); + } else if ( + (schemaArrays.has(key) || schemaChildren.has(key)) && + Array.isArray(child) + ) { + for (const item of child) pending.push({ value: item, scope }); + } else if (schemaChildren.has(key)) + pending.push({ value: child, scope }); + } + } + } + + enter( + schema: SchemaObject, + parent: JsonSchemaScope | undefined, + ): JsonSchemaScope | undefined { + if (parent === undefined) return undefined; + const scope = this.#scope(schema, parent); + for (let current = scope; current !== undefined; current = current.parent) + if (this.#ambiguous.has(current)) return undefined; + return scope?.dialect === undefined ? undefined : scope; + } + + #scope( + schema: SchemaObject, + parent: JsonSchemaScope, + ): JsonSchemaScope | undefined { + if (schema === parent.root) return parent; + if (schema.$id === undefined) return parent; + let cached = this.#scopes.get(schema); + if (cached?.has(parent)) return cached.get(parent); + if (cached === undefined) { + cached = new WeakMap(); + this.#scopes.set(schema, cached); + } + let scope: JsonSchemaScope | undefined; + if ( + typeof schema.$id === "string" && + schema.$id !== "" && + (!schema.$id.includes("#") || + (schema.$id !== "#" && + schema.$id.indexOf("#") === schema.$id.length - 1)) + ) { + let baseUri: string | undefined; + try { + if (parent.baseUri !== undefined || URL.canParse(schema.$id)) { + const url = new URL(schema.$id, parent.baseUri); + url.hash = ""; + baseUri = url.href; + } + scope = { + root: schema, + baseUri, + dialect: dialectOf(schema, parent.dialect), + parent, + }; + if (baseUri !== undefined) { + const existing = this.#identities.get(baseUri); + if (existing !== undefined && existing.root !== schema) { + this.#ambiguous.add(existing); + this.#ambiguous.add(scope); + } else if (existing !== undefined) scope = existing; + else this.#identities.set(baseUri, scope); + } else { + let identities = this.#relativeIdentities.get(parent); + if (identities === undefined) { + identities = new Map(); + this.#relativeIdentities.set(parent, identities); + } + const identity = schema.$id.replace(/#$/u, ""); + const existing = identities.get(identity); + if (existing !== undefined && existing.root !== schema) { + this.#ambiguous.add(existing); + this.#ambiguous.add(scope); + } else if (existing !== undefined) scope = existing; + else identities.set(identity, scope); + } + } catch { + // An opaque or unknown URI base must not be replaced with a guessed scope. + } + } + // Empty/fragment IDs can alias a resource or be legacy anchors. Keep them + // unresolved rather than resetting the pointer root to that subschema. + cached.set(parent, scope); + return scope; + } + + *references(): IterableIterator<{ + readonly scope: JsonSchemaScope; + readonly target: LocalReference; + }> { + for (const { scope, reference, keyword } of this.#referenceSites) + yield { + scope, + target: + keyword === "$ref" + ? this.resolve(scope, reference) + : { kind: "unresolved", reason: "dynamic" }, + }; + } + + resolve( + scope: JsonSchemaScope | undefined, + reference: string, + ): LocalReference { + if (scope === undefined || this.enter(scope.root, scope) === undefined) + return { kind: "unresolved", reason: "scope" }; + if (!reference.startsWith("#")) + return { kind: "unresolved", reason: "external" }; + let fragment: string; + try { + fragment = decodeURIComponent(reference.slice(1)); + } catch { + return { kind: "unresolved", reason: "fragment" }; + } + if (fragment === "") return { kind: "target", value: scope.root, path: [] }; + if (!fragment.startsWith("/")) return { kind: "anchor" }; + let value: unknown = scope.root; + const path: string[] = []; + for (const token of fragment.slice(1).split("/")) { + if (/~(?:[^01]|$)/u.test(token)) + return { kind: "unresolved", reason: "pointer" }; + const key = token.replaceAll("~1", "/").replaceAll("~0", "~"); + if ( + value === null || + typeof value !== "object" || + !Object.hasOwn(value, key) || + (Array.isArray(value) && !/^(?:0|[1-9][0-9]*)$/u.test(key)) + ) + return { kind: "unresolved", reason: "pointer" }; + if (value !== scope.root && isObject(value) && "$id" in value) + return { kind: "unresolved", reason: "resource-boundary" }; + path.push(key); + value = Reflect.get(value, key); + } + return { kind: "target", value, path }; + } +} diff --git a/src/contracts/toolInputReferences.test.ts b/src/contracts/toolInputReferences.test.ts index 9f61277279..cb1f0765a1 100644 --- a/src/contracts/toolInputReferences.test.ts +++ b/src/contracts/toolInputReferences.test.ts @@ -608,3 +608,132 @@ describe("advertised Boolean schema composition", () => { expect(presented.examples).toEqual([literal]); }); }); + +it.each(["draft-2020-12", "draft-07"] as const)( + "describes references from their containing resource in %s", + (dialect) => { + const presented = presentInputJsonSchema( + { + $schema: + dialect === "draft-07" + ? "http://json-schema.org/draft-07/schema#" + : "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/root.json", + type: "object", + definitions: { value: { type: "string", description: "Outer text." } }, + properties: { + child: { + $id: "child.json", + type: "object", + definitions: { + value: { type: "number", description: "Nested number." }, + }, + properties: { value: { $ref: "#/definitions/value" } }, + }, + }, + }, + () => undefined, + ); + expect(presented).toMatchObject({ + properties: { + child: { properties: { value: { description: "Nested number." } } }, + }, + }); + }, +); + +it.each(["draft-2020-12", "draft-07"] as const)( + "keeps root union resource identities when lifting would change relative refs in %s", + (dialect) => { + const source = { + $schema: + dialect === "draft-07" + ? "http://json-schema.org/draft-07/schema#" + : "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/root.json", + anyOf: ["first", "second"].map((kind) => ({ + $id: `https://example.test/${kind}/schema.json`, + type: "object", + properties: { kind: { const: kind }, value: { $ref: "child.json" } }, + required: ["kind", "value"], + additionalProperties: false, + })), + }; + const presented = presentInputJsonSchema(source, () => undefined); + const createAjv = () => { + const ajv = + dialect === "draft-07" + ? new Ajv({ strict: false }) + : new Ajv2020({ strict: false }); + ajv.addSchema({ + $id: "https://example.test/first/child.json", + type: "string", + }); + ajv.addSchema({ + $id: "https://example.test/second/child.json", + type: "number", + }); + return ajv; + }; + const canonical = createAjv().compile(source); + const advertised = createAjv().compile(presented); + for (const kind of ["first", "second"]) + for (const value of [7, "seven", null]) + expect(advertised({ kind, value })).toBe(canonical({ kind, value })); + }, +); + +it("does not lift properties that still refer to deleted root-union locations", () => { + const source = { + anyOf: [ + { + type: "object", + properties: { value: { $ref: "#/anyOf/1/properties/value" } }, + required: ["value"], + }, + { + type: "object", + properties: { value: { type: "number" } }, + required: ["value"], + }, + ], + }; + const presented = presentInputJsonSchema(source, () => undefined); + const canonical = new Ajv2020({ strict: false }).compile(source); + const advertised = new Ajv2020({ strict: false }).compile(presented); + for (const value of [7, "seven", null]) + expect(advertised({ value })).toBe(canonical({ value })); +}); + +it.each(["$ref", "$dynamicRef"])( + "preserves the root union used by recursive %s children", + (keyword) => { + const source = { + anyOf: [ + { + type: "object", + properties: { end: { const: "done" } }, + required: ["end"], + additionalProperties: false, + }, + { + type: "object", + properties: { next: { [keyword]: "#" } }, + required: ["next"], + additionalProperties: false, + }, + ], + }; + const presented = presentInputJsonSchema(source, () => undefined); + expect(presented.anyOf).toBeDefined(); + const canonical = new Ajv2020({ strict: false }).compile(source); + const advertised = new Ajv2020({ strict: false }).compile(presented); + for (const value of [ + { end: "done" }, + { next: { end: "done" } }, + { next: {} }, + { next: { end: "invalid" } }, + ]) + expect(advertised(value)).toBe(canonical(value)); + }, +); diff --git a/src/contracts/toolOutputReferences.test.ts b/src/contracts/toolOutputReferences.test.ts index f5e53656fb..ee66a47112 100644 --- a/src/contracts/toolOutputReferences.test.ts +++ b/src/contracts/toolOutputReferences.test.ts @@ -327,3 +327,33 @@ it.each(["relative/", "https://example.test/nested.json"])( expect(projected).not.toHaveProperty("$id"); }, ); + +it("does not synthesize an output base for duplicate embedded resource identifiers", () => { + const canonical = z.looseObject({}).meta({ + type: "object", + properties: { + before: { $id: "https://example.test/repeated", type: "string" }, + after: { $id: "https://example.test/repeated", type: "number" }, + }, + }); + const projected = toolOutputSchemaWithMetadata({ + ...toolContract("binary_session"), + outputSchema: canonical, + })["~standard"].jsonSchema.output({ target: "draft-2020-12" }); + expect(projected).not.toHaveProperty("$id"); +}); + +it.each(["$dynamicRef", "$recursiveRef"])( + "preserves the output base for %s", + (keyword) => { + const canonical = z.looseObject({}).meta({ + type: "object", + properties: { next: { [keyword]: "#" } }, + }); + const projected = toolOutputSchemaWithMetadata({ + ...toolContract("binary_session"), + outputSchema: canonical, + })["~standard"].jsonSchema.output({ target: "draft-2020-12" }); + expect(projected).not.toHaveProperty("$id"); + }, +); diff --git a/src/contracts/toolSchemaMetadata.ts b/src/contracts/toolSchemaMetadata.ts index d0468ec48c..5344f66c01 100644 --- a/src/contracts/toolSchemaMetadata.ts +++ b/src/contracts/toolSchemaMetadata.ts @@ -1,3 +1,7 @@ +import { + JsonSchemaReferences, + type JsonSchemaScope, +} from "./jsonSchemaReferences.js"; import { z } from "zod"; import type { ToolContract } from "./toolContractTypes.js"; @@ -164,10 +168,14 @@ const hasRelativeSchemaReference = ( ): boolean => { // Local pointers can make even annotation values executable schemas. Keep // their resource root and traversal role separate from ordinary instance data. - const pending: { value: unknown; root: object; position: SchemaPosition }[] = - [{ value: schema, root: schema, position: "schema" }]; + const references = new JsonSchemaReferences(schema); + const pending: { + value: unknown; + scope: JsonSchemaScope | undefined; + position: SchemaPosition; + }[] = [{ value: schema, scope: references.rootScope, position: "schema" }]; const visited = new WeakMap< - object, + JsonSchemaScope, Record> >(); while (pending.length > 0) { @@ -175,26 +183,24 @@ const hasRelativeSchemaReference = ( const current = entry.value; if (current === null || typeof current !== "object") continue; const { position } = entry; - const root = - position !== "map" && - isSchemaRecord(current) && - typeof current.$id === "string" && - URL.canParse(current.$id) - ? current - : entry.root; - let positions = visited.get(root); + const scope = + position !== "map" && isSchemaRecord(current) + ? references.enter(current, entry.scope) + : entry.scope; + if (scope === undefined) return true; + let positions = visited.get(scope); if (positions === undefined) { positions = { schema: new WeakSet(), map: new WeakSet(), unknown: new WeakSet(), }; - visited.set(root, positions); + visited.set(scope, positions); } if (positions[position].has(current)) continue; positions[position].add(current); if (Array.isArray(current)) { - for (const value of current) pending.push({ value, root, position }); + for (const value of current) pending.push({ value, scope, position }); continue; } for (const [key, value] of Object.entries(current)) { @@ -203,14 +209,15 @@ const hasRelativeSchemaReference = ( ["$id", "$ref", "$dynamicRef", "$recursiveRef"].includes(key) && typeof value === "string" ) { + if (key === "$dynamicRef" || key === "$recursiveRef") return true; if (!value.startsWith("#") && !URL.canParse(value)) return true; if (key !== "$id" && value.startsWith("#")) { - const target = localSchemaReference(root, value); + const target = references.resolve(scope, value); // Unknown anchors are already examined at their schema positions. // Unresolvable pointers retain the existing reference base. - if (target === undefined) return true; + if (target.kind === "unresolved") return true; if (target.kind === "target") - pending.push({ value: target.value, root, position: "schema" }); + pending.push({ value: target.value, scope, position: "schema" }); } } if (position === "schema" && schemaLiteralValues.has(key)) continue; @@ -224,44 +231,12 @@ const hasRelativeSchemaReference = ( ? "schema" : "unknown"; if (value !== null && typeof value === "object") - pending.push({ value, root, position: childPosition }); + pending.push({ value, scope, position: childPosition }); } } return false; }; -/** Decode URI-fragment JSON Pointers without interpreting literal data as schemas. */ -const localSchemaReference = ( - root: object, - reference: string, -): { kind: "target"; value: unknown } | { kind: "anchor" } | undefined => { - let fragment: string; - try { - fragment = decodeURIComponent(reference.slice(1)); - } catch { - return undefined; - } - if (fragment === "") return { kind: "target", value: root }; - if (!fragment.startsWith("/")) return { kind: "anchor" }; - let value: unknown = root; - for (const token of fragment.slice(1).split("/")) { - if (/~(?:[^01]|$)/u.test(token)) return undefined; - const key = token.replaceAll("~1", "/").replaceAll("~0", "~"); - if ( - value === null || - typeof value !== "object" || - !Object.hasOwn(value, key) - ) - return undefined; - // A pointer can cross a resource boundary even inside literal data. - // Preserve the original projection rather than guess the reference scope. - if (value !== root && isSchemaRecord(value) && "$id" in value) - return undefined; - value = Reflect.get(value, key); - } - return { kind: "target", value }; -}; - const withAdvertisedJsonSchema = ( canonical: Schema, io: "input" | "output", diff --git a/tests/boundary/mcp/compactInputSchemaMcp.test.ts b/tests/boundary/mcp/compactInputSchemaMcp.test.ts index d63c1223e3..30a1221c59 100644 --- a/tests/boundary/mcp/compactInputSchemaMcp.test.ts +++ b/tests/boundary/mcp/compactInputSchemaMcp.test.ts @@ -459,3 +459,77 @@ describe("MCP compact input schema canonical enforcement", () => { expect(ajv.validateSchema(advertised.inputSchema)).toBe(true); }); }); + +it.each(["full", "compact"] as const)( + "preserves nested resource validation through the %s SDK catalog and canonical handler", + async (profile) => { + const source = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/root.json", + type: "object", + definitions: { + value: { type: "string" }, + child: { + $id: "child.json", + type: "object", + definitions: { value: { type: "number" } }, + properties: { value: { $ref: "#/definitions/value" } }, + required: ["value"], + additionalProperties: false, + }, + }, + properties: { child: { $ref: "#/definitions/child" } }, + required: ["child"], + additionalProperties: false, + }; + const canonical = new Ajv2020({ strict: false }).compile(source); + const registration = toolRegistrationOptions( + toolContract("binary_session"), + ); + const inputSchema = { + "~standard": { + ...registration.inputSchema["~standard"], + vendor: "rea-resource-fixture", + jsonSchema: { + ...registration.inputSchema["~standard"].jsonSchema, + input: () => source, + }, + validate: (value: unknown) => + canonical(value) + ? { value } + : { issues: [{ message: "Invalid resource value" }] }, + }, + }; + const server = profile === "compact" ? compactServer() : fullServer(); + let calls = 0; + server.registerTool("resource_fixture", { inputSchema }, async () => { + calls++; + return { content: [{ type: "text", text: "accepted" }] }; + }); + const client = new Client({ name: "resource-scope", version: "1" }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + try { + await server.connect(st); + await client.connect(ct); + const definition = (await client.listTools()).tools.find( + ({ name }) => name === "resource_fixture", + ); + if (definition === undefined) throw new Error("Missing resource fixture"); + const advertised = new Ajv2020({ strict: false }).compile( + definition.inputSchema, + ); + for (const value of [7, "seven"]) { + const args = { child: { value } }; + expect(advertised(args)).toBe(canonical(args)); + const result = await client.callTool({ + name: "resource_fixture", + arguments: args, + }); + expect(result.isError === true).toBe(!canonical(args)); + } + expect(calls).toBe(1); + } finally { + await Promise.allSettled([client.close(), server.close()]); + } + }, +);