@@ -22,101 +22,115 @@ export type SchemaOutput<T extends AnySchema> = z.output<T>;
2222
2323/**
2424 * Resolves all local `$ref` pointers in a JSON Schema by inlining the
25- * referenced definitions. Removes `$defs`/`definitions` from the output.
25+ * referenced definitions.
2626 *
2727 * - Caches resolved defs to avoid redundant work with diamond references
2828 * (A→B→D, A→C→D — D is resolved once and reused).
29- * - Throws on cycles — recursive schemas cannot be represented without `$ref`
30- * and LLMs cannot handle them. Fail loud so the developer knows to
31- * restructure their schema.
29+ * - Gracefully handles cycles — cyclic `$ref` are left in place with their
30+ * `$defs` entries preserved. Non-cyclic refs in the same schema are still
31+ * fully inlined. This avoids breaking existing servers that have recursive
32+ * schemas which work (degraded) today.
3233 * - Preserves sibling keywords alongside `$ref` per JSON Schema 2020-12
3334 * (e.g. `{ "$ref": "...", "description": "override" }`).
3435 *
3536 * @internal Exported for testing only.
3637 */
3738export function dereferenceLocalRefs ( schema : Record < string , unknown > ) : Record < string , unknown > {
38- const defs : Record < string , unknown > =
39- ( schema [ '$defs' ] as Record < string , unknown > ) ?? ( schema [ 'definitions' ] as Record < string , unknown > ) ?? { } ;
40-
41- // Cache resolved defs to avoid redundant traversal on diamond references.
42- // Note: cached values are shared by reference. This is safe because schemas
43- // are treated as immutable after generation. If a consumer mutates a schema,
44- // they'd need to deep-clone it first regardless.
45- const cache = new Map < string , unknown > ( ) ;
46-
47- function resolve ( node : unknown , stack : Set < string > ) : unknown {
39+ // "$defs" is the standard keyword since JSON Schema 2019-09.
40+ // See: https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.4
41+ // "definitions" is the legacy equivalent from drafts 04–07.
42+ // See: https://json-schema.org/draft-07/json-schema-validation#section-9
43+ // If both exist (malformed schema), "$defs" takes precedence.
44+ const defsKey = '$defs' in schema ? '$defs' : 'definitions' in schema ? 'definitions' : undefined ;
45+ const defs : Record < string , unknown > = defsKey ? ( schema [ defsKey ] as Record < string , unknown > ) : { } ;
46+
47+ // No definitions container — nothing to inline.
48+ // Note: $ref: "#" (root self-reference) is intentionally not handled — no schema
49+ // library produces it, no other MCP SDK handles it, and it's always cyclic.
50+ if ( ! defsKey ) return schema ;
51+
52+ // Cache resolved defs to avoid redundant traversal on diamond references
53+ // (A→B→D, A→C→D — D is resolved once and reused). Cached values are shared
54+ // by reference, which is safe because schemas are immutable after generation.
55+ const resolvedDefs = new Map < string , unknown > ( ) ;
56+ // Def names where a cycle was detected — these $ref are left in place
57+ // and their $defs entries must be preserved in the output.
58+ const cyclicDefs = new Set < string > ( ) ;
59+
60+ /**
61+ * Recursively inlines `$ref` pointers in a JSON Schema node by replacing
62+ * them with the referenced definition content.
63+ *
64+ * @param node - The current schema node being traversed.
65+ * @param stack - Def names currently being inlined (ancestor chain). If a
66+ * def is encountered while already on the stack, it's a cycle — the
67+ * `$ref` is left in place and the def name is added to `cyclicDefs`.
68+ */
69+ function inlineRefs ( node : unknown , stack : Set < string > ) : unknown {
4870 if ( node === null || typeof node !== 'object' ) return node ;
49- if ( Array . isArray ( node ) ) return node . map ( item => resolve ( item , stack ) ) ;
71+ if ( Array . isArray ( node ) ) return node . map ( item => inlineRefs ( item , stack ) ) ;
5072
5173 const obj = node as Record < string , unknown > ;
5274
53- if ( typeof obj [ '$ref' ] === 'string' ) {
54- const ref = obj [ '$ref' ] as string ;
55-
56- // Collect sibling keywords (JSON Schema 2020-12 allows keywords alongside $ref)
57- const { $ref : _ref , ...siblings } = obj ;
58- void _ref ;
75+ // JSON Schema 2020-12 allows keywords alongside $ref (e.g. description, default).
76+ // Destructure to get the ref target and any sibling keywords to merge later.
77+ const { $ref : ref , ...siblings } = obj ;
78+ if ( typeof ref === 'string' ) {
5979 const hasSiblings = Object . keys ( siblings ) . length > 0 ;
6080
6181 let resolved : unknown ;
6282
63- if ( ref === '#' ) {
64- // Self-referencing root
65- if ( stack . has ( ref ) ) {
66- throw new Error (
67- 'Recursive schema detected: the root schema references itself. ' +
68- 'MCP tool schemas cannot contain cycles because LLMs cannot resolve $ref pointers.'
69- ) ;
70- }
71- const { $defs : _defs , definitions : _definitions , ... rest } = schema ;
72- void _defs ;
73- void _definitions ;
74- stack . add ( ref ) ;
75- resolved = resolve ( rest , stack ) ;
76- stack . delete ( ref ) ;
83+ // Local definition reference: #/$defs/Name or #/definitions/Name
84+ const prefix = `#/ ${ defsKey } /` ;
85+ if ( ! ref . startsWith ( prefix ) ) return obj ; // Non-local $ref (external URL, etc.) — leave as-is
86+
87+ const defName = ref . slice ( prefix . length ) ;
88+ const def = defs [ defName ] ;
89+ if ( def === undefined ) return obj ; // Unknown def — leave as-is
90+ if ( stack . has ( defName ) ) {
91+ cyclicDefs . add ( defName ) ;
92+ return obj ; // Cycle — leave $ref in place
93+ }
94+
95+ if ( resolvedDefs . has ( defName ) ) {
96+ resolved = resolvedDefs . get ( defName ) ;
7797 } else {
78- // Local definition: #/$defs/Name or #/definitions/Name
79- const match = ref . match ( / ^ # \/ (?: \$ d e f s | d e f i n i t i o n s ) \/ ( .+ ) $ / ) ;
80- if ( ! match ) return obj ; // Non-local $ref — leave as-is
81-
82- const defName = match [ 1 ] ! ;
83- const def = defs [ defName ] ;
84- if ( def === undefined ) return obj ; // Unknown def — leave as-is
85-
86- if ( stack . has ( defName ) ) {
87- throw new Error (
88- `Recursive schema detected: cycle through definition "${ defName } ". ` +
89- 'MCP tool schemas cannot contain cycles because LLMs cannot resolve $ref pointers.'
90- ) ;
91- }
92-
93- if ( cache . has ( defName ) ) {
94- resolved = cache . get ( defName ) ;
95- } else {
96- stack . add ( defName ) ;
97- resolved = resolve ( def , stack ) ;
98- stack . delete ( defName ) ;
99- cache . set ( defName , resolved ) ;
100- }
98+ stack . add ( defName ) ;
99+ resolved = inlineRefs ( def , stack ) ;
100+ stack . delete ( defName ) ;
101+ resolvedDefs . set ( defName , resolved ) ;
101102 }
102103
103- // Merge sibling keywords onto the resolved schema
104+ // Merge sibling keywords onto the resolved definition
104105 if ( hasSiblings && resolved !== null && typeof resolved === 'object' && ! Array . isArray ( resolved ) ) {
105- const resolvedSiblings = Object . fromEntries ( Object . entries ( siblings ) . map ( ( [ k , v ] ) => [ k , resolve ( v , stack ) ] ) ) ;
106+ const resolvedSiblings = Object . fromEntries ( Object . entries ( siblings ) . map ( ( [ k , v ] ) => [ k , inlineRefs ( v , stack ) ] ) ) ;
106107 return { ...( resolved as Record < string , unknown > ) , ...resolvedSiblings } ;
107108 }
108109 return resolved ;
109110 }
110111
112+ // Regular object — recurse into values, skipping root-level $defs container
111113 const result : Record < string , unknown > = { } ;
112114 for ( const [ key , value ] of Object . entries ( obj ) ) {
113115 if ( obj === schema && ( key === '$defs' || key === 'definitions' ) ) continue ;
114- result [ key ] = resolve ( value , stack ) ;
116+ result [ key ] = inlineRefs ( value , stack ) ;
115117 }
116118 return result ;
117119 }
118120
119- return resolve ( schema , new Set ( ) ) as Record < string , unknown > ;
121+ const resolved = inlineRefs ( schema , new Set ( ) ) as Record < string , unknown > ;
122+
123+ // Re-attach $defs only for cyclic definitions, using their resolved/cached
124+ // versions so that any non-cyclic refs inside them are already inlined.
125+ if ( defsKey && cyclicDefs . size > 0 ) {
126+ const prunedDefs : Record < string , unknown > = { } ;
127+ for ( const name of cyclicDefs ) {
128+ prunedDefs [ name ] = resolvedDefs . get ( name ) ?? defs [ name ] ;
129+ }
130+ resolved [ defsKey ] = prunedDefs ;
131+ }
132+
133+ return resolved ;
120134}
121135
122136/**
0 commit comments