Skip to content

Commit b20f749

Browse files
committed
feat: graceful degradation for cyclic schemas, code quality tightening
- Cyclic $ref left in place with $defs preserved (no throw) - Non-cyclic refs still fully inlined - Single-pass cycle tracking via cyclicDefs Set - resolve() -> inlineRefs() with JSDoc - Regex -> startsWith + slice - Preserved $defs use cached resolved versions (fixes dangling $ref bug) - Tests rewritten as declarative toEqual on full expected output - Added multi-hop cycle test with shuffled $defs order - ADR updated with review traceability and test philosophy
1 parent ccf0396 commit b20f749

3 files changed

Lines changed: 239 additions & 177 deletions

File tree

packages/core/src/util/schema.ts

Lines changed: 76 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
3738
export 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(/^#\/(?:\$defs|definitions)\/(.+)$/);
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

Comments
 (0)