Skip to content

Commit 8c8d632

Browse files
fix(mcp): fix cursor schema transformation issue with recursive references
1 parent 9678ab3 commit 8c8d632

File tree

2 files changed

+111
-4
lines changed

2 files changed

+111
-4
lines changed

packages/mcp-server/src/compat.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,11 @@ export function removeTopLevelUnions(tool: Tool): Tool[] {
148148
});
149149
}
150150

151-
function findUsedDefs(schema: JSONSchema, defs: Record<string, JSONSchema>): Record<string, JSONSchema> {
151+
function findUsedDefs(
152+
schema: JSONSchema,
153+
defs: Record<string, JSONSchema>,
154+
visited: Set<string> = new Set(),
155+
): Record<string, JSONSchema> {
152156
const usedDefs: Record<string, JSONSchema> = {};
153157

154158
if (typeof schema !== 'object' || schema === null) {
@@ -160,23 +164,28 @@ function findUsedDefs(schema: JSONSchema, defs: Record<string, JSONSchema>): Rec
160164
if (refParts[0] === '#' && refParts[1] === '$defs' && refParts[2]) {
161165
const defName = refParts[2];
162166
const def = defs[defName];
163-
if (def) {
167+
if (def && !visited.has(schema.$ref)) {
164168
usedDefs[defName] = def;
165-
Object.assign(usedDefs, findUsedDefs(def, defs));
169+
visited.add(schema.$ref);
170+
Object.assign(usedDefs, findUsedDefs(def, defs, visited));
171+
visited.delete(schema.$ref);
166172
}
167173
}
168174
return usedDefs;
169175
}
170176

171177
for (const key in schema) {
172178
if (key !== '$defs' && typeof schema[key] === 'object' && schema[key] !== null) {
173-
Object.assign(usedDefs, findUsedDefs(schema[key] as JSONSchema, defs));
179+
Object.assign(usedDefs, findUsedDefs(schema[key] as JSONSchema, defs, visited));
174180
}
175181
}
176182

177183
return usedDefs;
178184
}
179185

186+
// Export for testing
187+
export { findUsedDefs };
188+
180189
/**
181190
* Inlines all $refs in a schema, eliminating $defs.
182191
* If a circular reference is detected, the circular property is removed.

packages/mcp-server/tests/compat.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
inlineRefs,
66
applyCompatibilityTransformations,
77
removeFormats,
8+
findUsedDefs,
89
} from '../src/compat';
910
import { Tool } from '@modelcontextprotocol/sdk/types.js';
1011
import { JSONSchema } from '../src/compat';
@@ -316,6 +317,103 @@ describe('removeAnyOf', () => {
316317
});
317318
});
318319

320+
describe('findUsedDefs', () => {
321+
it('should handle circular references without stack overflow', () => {
322+
const defs = {
323+
person: {
324+
type: 'object',
325+
properties: {
326+
name: { type: 'string' },
327+
friend: { $ref: '#/$defs/person' }, // Circular reference
328+
},
329+
},
330+
};
331+
332+
const schema = {
333+
type: 'object',
334+
properties: {
335+
user: { $ref: '#/$defs/person' },
336+
},
337+
};
338+
339+
// This should not throw a stack overflow error
340+
expect(() => {
341+
const result = findUsedDefs(schema, defs);
342+
expect(result).toHaveProperty('person');
343+
}).not.toThrow();
344+
});
345+
346+
it('should handle indirect circular references without stack overflow', () => {
347+
const defs = {
348+
node: {
349+
type: 'object',
350+
properties: {
351+
value: { type: 'string' },
352+
child: { $ref: '#/$defs/childNode' },
353+
},
354+
},
355+
childNode: {
356+
type: 'object',
357+
properties: {
358+
value: { type: 'string' },
359+
parent: { $ref: '#/$defs/node' }, // Indirect circular reference
360+
},
361+
},
362+
};
363+
364+
const schema = {
365+
type: 'object',
366+
properties: {
367+
root: { $ref: '#/$defs/node' },
368+
},
369+
};
370+
371+
// This should not throw a stack overflow error
372+
expect(() => {
373+
const result = findUsedDefs(schema, defs);
374+
expect(result).toHaveProperty('node');
375+
expect(result).toHaveProperty('childNode');
376+
}).not.toThrow();
377+
});
378+
379+
it('should find all used definitions in non-circular schemas', () => {
380+
const defs = {
381+
user: {
382+
type: 'object',
383+
properties: {
384+
name: { type: 'string' },
385+
address: { $ref: '#/$defs/address' },
386+
},
387+
},
388+
address: {
389+
type: 'object',
390+
properties: {
391+
street: { type: 'string' },
392+
city: { type: 'string' },
393+
},
394+
},
395+
unused: {
396+
type: 'object',
397+
properties: {
398+
data: { type: 'string' },
399+
},
400+
},
401+
};
402+
403+
const schema = {
404+
type: 'object',
405+
properties: {
406+
person: { $ref: '#/$defs/user' },
407+
},
408+
};
409+
410+
const result = findUsedDefs(schema, defs);
411+
expect(result).toHaveProperty('user');
412+
expect(result).toHaveProperty('address');
413+
expect(result).not.toHaveProperty('unused');
414+
});
415+
});
416+
319417
describe('inlineRefs', () => {
320418
it('should return the original schema if it does not contain $refs', () => {
321419
const schema: JSONSchema = {

0 commit comments

Comments
 (0)