Skip to content

Commit 668d7aa

Browse files
fix(@langchain/mcp-adapters): resolve $defs/$ref in JSON schemas for Pydantic v2 compatibility (#9525)
1 parent 0fe11c4 commit 668d7aa

File tree

3 files changed

+387
-1
lines changed

3 files changed

+387
-1
lines changed

.changeset/heavy-news-raise.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@langchain/mcp-adapters": patch
3+
---
4+
5+
fix(@langchain/mcp-adapters): resolve $defs/$ref in JSON schemas for Pydantic v2 compatibility

libs/langchain-mcp-adapters/src/tests/tools.test.ts

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,264 @@ describe("Simplified Tool Adapter Tests", () => {
251251
expect(tools[1].name).toBe("tool2");
252252
});
253253

254+
test("should handle JSON schemas with $defs references (Pydantic v2 style)", async () => {
255+
// This schema is similar to what Pydantic v2 generates with nested models
256+
const pydanticV2Schema = {
257+
type: "object" as const,
258+
properties: {
259+
items: {
260+
type: "array",
261+
items: {
262+
$ref: "#/$defs/DataItem",
263+
},
264+
description: "List of items",
265+
},
266+
metadata: {
267+
$ref: "#/$defs/Metadata",
268+
description: "Response metadata",
269+
},
270+
},
271+
required: ["items", "metadata"],
272+
$defs: {
273+
DataItem: {
274+
type: "object",
275+
properties: {
276+
id: { type: "string", description: "Item ID" },
277+
name: { type: "string", description: "Item name" },
278+
value: { type: "number", description: "Item value" },
279+
},
280+
required: ["id", "name", "value"],
281+
},
282+
Metadata: {
283+
type: "object",
284+
properties: {
285+
total_count: { type: "integer", description: "Total count" },
286+
timestamp: { type: "string", description: "Timestamp" },
287+
},
288+
required: ["total_count", "timestamp"],
289+
},
290+
},
291+
};
292+
293+
mockClient.listTools.mockReturnValueOnce(
294+
Promise.resolve({
295+
tools: [
296+
{
297+
name: "query_data",
298+
description: "Query tool that returns nested response",
299+
inputSchema: pydanticV2Schema,
300+
},
301+
],
302+
})
303+
);
304+
305+
mockClient.callTool.mockImplementation((params) => {
306+
const args = params.arguments as {
307+
items: Array<{ id: string; name: string; value: number }>;
308+
metadata: { total_count: number; timestamp: string };
309+
};
310+
return Promise.resolve({
311+
content: [
312+
{
313+
type: "text",
314+
text: `Received ${args.items.length} items with total_count=${args.metadata.total_count}`,
315+
},
316+
],
317+
});
318+
});
319+
320+
// Load tools - this should not throw even though schema has $defs
321+
const tools = await loadMcpTools(
322+
"mockServer(should handle $defs)",
323+
mockClient as Client
324+
);
325+
326+
expect(tools.length).toBe(1);
327+
expect(tools[0].name).toBe("query_data");
328+
329+
// Invoke the tool with valid input matching the dereferenced schema
330+
const result = await tools[0].invoke({
331+
items: [{ id: "1", name: "Test", value: 100.0 }],
332+
metadata: { total_count: 1, timestamp: "2024-01-01" },
333+
});
334+
335+
expect(result).toBe("Received 1 items with total_count=1");
336+
expect(mockClient.callTool).toHaveBeenCalledWith({
337+
name: "query_data",
338+
arguments: {
339+
items: [{ id: "1", name: "Test", value: 100.0 }],
340+
metadata: { total_count: 1, timestamp: "2024-01-01" },
341+
},
342+
});
343+
});
344+
345+
test("should handle JSON schemas with definitions (older JSON Schema style)", async () => {
346+
// Some tools use 'definitions' instead of '$defs'
347+
const schemaWithDefinitions = {
348+
type: "object" as const,
349+
properties: {
350+
user: {
351+
$ref: "#/definitions/User",
352+
},
353+
},
354+
required: ["user"],
355+
definitions: {
356+
User: {
357+
type: "object",
358+
properties: {
359+
name: { type: "string" },
360+
email: { type: "string" },
361+
},
362+
required: ["name", "email"],
363+
},
364+
},
365+
};
366+
367+
mockClient.listTools.mockReturnValueOnce(
368+
Promise.resolve({
369+
tools: [
370+
{
371+
name: "create_user",
372+
description: "Create a user",
373+
inputSchema: schemaWithDefinitions,
374+
},
375+
],
376+
})
377+
);
378+
379+
mockClient.callTool.mockImplementation((params) => {
380+
const args = params.arguments as {
381+
user: { name: string; email: string };
382+
};
383+
return Promise.resolve({
384+
content: [
385+
{
386+
type: "text",
387+
text: `Created user: ${args.user.name}`,
388+
},
389+
],
390+
});
391+
});
392+
393+
const tools = await loadMcpTools(
394+
"mockServer(should handle definitions)",
395+
mockClient as Client
396+
);
397+
398+
expect(tools.length).toBe(1);
399+
400+
const result = await tools[0].invoke({
401+
user: { name: "John", email: "john@example.com" },
402+
});
403+
404+
expect(result).toBe("Created user: John");
405+
});
406+
407+
test("should handle deeply nested $ref references", async () => {
408+
const deeplyNestedSchema = {
409+
type: "object" as const,
410+
properties: {
411+
order: {
412+
$ref: "#/$defs/Order",
413+
},
414+
},
415+
required: ["order"],
416+
$defs: {
417+
Order: {
418+
type: "object",
419+
properties: {
420+
id: { type: "string" },
421+
customer: {
422+
$ref: "#/$defs/Customer",
423+
},
424+
items: {
425+
type: "array",
426+
items: {
427+
$ref: "#/$defs/OrderItem",
428+
},
429+
},
430+
},
431+
required: ["id", "customer", "items"],
432+
},
433+
Customer: {
434+
type: "object",
435+
properties: {
436+
name: { type: "string" },
437+
address: {
438+
$ref: "#/$defs/Address",
439+
},
440+
},
441+
required: ["name", "address"],
442+
},
443+
Address: {
444+
type: "object",
445+
properties: {
446+
street: { type: "string" },
447+
city: { type: "string" },
448+
},
449+
required: ["street", "city"],
450+
},
451+
OrderItem: {
452+
type: "object",
453+
properties: {
454+
product: { type: "string" },
455+
quantity: { type: "integer" },
456+
},
457+
required: ["product", "quantity"],
458+
},
459+
},
460+
};
461+
462+
mockClient.listTools.mockReturnValueOnce(
463+
Promise.resolve({
464+
tools: [
465+
{
466+
name: "create_order",
467+
description: "Create an order",
468+
inputSchema: deeplyNestedSchema,
469+
},
470+
],
471+
})
472+
);
473+
474+
mockClient.callTool.mockImplementation(() => {
475+
return Promise.resolve({
476+
content: [
477+
{
478+
type: "text",
479+
text: "Order created successfully",
480+
},
481+
],
482+
});
483+
});
484+
485+
const tools = await loadMcpTools(
486+
"mockServer(should handle deeply nested refs)",
487+
mockClient as Client
488+
);
489+
490+
expect(tools.length).toBe(1);
491+
492+
const result = await tools[0].invoke({
493+
order: {
494+
id: "order-123",
495+
customer: {
496+
name: "Jane Doe",
497+
address: {
498+
street: "123 Main St",
499+
city: "Springfield",
500+
},
501+
},
502+
items: [
503+
{ product: "Widget", quantity: 2 },
504+
{ product: "Gadget", quantity: 1 },
505+
],
506+
},
507+
});
508+
509+
expect(result).toBe("Order created successfully");
510+
});
511+
254512
test("should load tools with specified response format", async () => {
255513
// Set up mock response with input schema
256514
mockClient.listTools.mockReturnValueOnce(

0 commit comments

Comments
 (0)