Skip to content

Commit dbc15df

Browse files
authored
🔳 fix: Bare Object MCP Tool Schemas as Passthrough (danny-avila#8637)
* 🔳 fix: Bare Object MCP Tool Schemas as Passthrough * ci: Add cases for handling complex object schemas in convertJsonSchemaToZod
1 parent 3a57047 commit dbc15df

File tree

2 files changed

+344
-1
lines changed

2 files changed

+344
-1
lines changed

packages/api/src/mcp/zod.spec.ts

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1485,4 +1485,334 @@ describe('convertJsonSchemaToZod', () => {
14851485
expect(resolvedKeywords.additionalProperties).toBe(false);
14861486
});
14871487
});
1488+
1489+
describe('Bare object schema handling for dynamic properties', () => {
1490+
it('should handle object type without explicit properties but expecting dynamic field definitions', () => {
1491+
// This simulates the Kintone add_fields tool schema
1492+
const schema: JsonSchemaType = {
1493+
type: 'object',
1494+
properties: {
1495+
app_id: {
1496+
type: 'number',
1497+
description: 'アプリID',
1498+
},
1499+
properties: {
1500+
type: 'object',
1501+
description: 'フィールドの設定(各フィールドには code, type, label の指定が必須)',
1502+
},
1503+
},
1504+
required: ['app_id', 'properties'],
1505+
};
1506+
1507+
const zodSchema = convertWithResolvedRefs(schema);
1508+
1509+
// Test case 1: Basic field definition
1510+
const testData1 = {
1511+
app_id: 810,
1512+
properties: {
1513+
minutes_id: {
1514+
code: 'minutes_id',
1515+
type: 'SINGLE_LINE_TEXT',
1516+
label: 'minutes_id',
1517+
},
1518+
},
1519+
};
1520+
1521+
// WITH THE FIX: Bare object schemas now act as passthrough
1522+
const result = zodSchema?.parse(testData1);
1523+
expect(result).toEqual(testData1); // Properties pass through!
1524+
});
1525+
1526+
it('should work when properties field has additionalProperties true', () => {
1527+
const schema: JsonSchemaType = {
1528+
type: 'object',
1529+
properties: {
1530+
app_id: {
1531+
type: 'number',
1532+
description: 'アプリID',
1533+
},
1534+
properties: {
1535+
type: 'object',
1536+
description: 'フィールドの設定(各フィールドには code, type, label の指定が必須)',
1537+
additionalProperties: true,
1538+
},
1539+
},
1540+
required: ['app_id', 'properties'],
1541+
};
1542+
1543+
const zodSchema = convertWithResolvedRefs(schema);
1544+
1545+
const testData = {
1546+
app_id: 810,
1547+
properties: {
1548+
minutes_id: {
1549+
code: 'minutes_id',
1550+
type: 'SINGLE_LINE_TEXT',
1551+
label: 'minutes_id',
1552+
},
1553+
},
1554+
};
1555+
1556+
const result = zodSchema?.parse(testData);
1557+
expect(result).toEqual(testData);
1558+
expect(result?.properties?.minutes_id).toBeDefined();
1559+
});
1560+
1561+
it('should work with proper field type definitions in additionalProperties', () => {
1562+
const schema: JsonSchemaType = {
1563+
type: 'object',
1564+
properties: {
1565+
app_id: {
1566+
type: 'number',
1567+
description: 'アプリID',
1568+
},
1569+
properties: {
1570+
type: 'object',
1571+
description: 'フィールドの設定(各フィールドには code, type, label の指定が必須)',
1572+
additionalProperties: {
1573+
type: 'object',
1574+
properties: {
1575+
type: { type: 'string' },
1576+
code: { type: 'string' },
1577+
label: { type: 'string' },
1578+
required: { type: 'boolean' },
1579+
options: {
1580+
type: 'object',
1581+
additionalProperties: {
1582+
type: 'object',
1583+
properties: {
1584+
label: { type: 'string' },
1585+
index: { type: 'string' },
1586+
},
1587+
},
1588+
},
1589+
},
1590+
required: ['type', 'code', 'label'],
1591+
},
1592+
},
1593+
},
1594+
required: ['app_id', 'properties'],
1595+
};
1596+
1597+
const zodSchema = convertWithResolvedRefs(schema);
1598+
1599+
// Test case 1: Simple text field
1600+
const testData1 = {
1601+
app_id: 810,
1602+
properties: {
1603+
minutes_id: {
1604+
code: 'minutes_id',
1605+
type: 'SINGLE_LINE_TEXT',
1606+
label: 'minutes_id',
1607+
required: false,
1608+
},
1609+
},
1610+
};
1611+
1612+
const result1 = zodSchema?.parse(testData1);
1613+
expect(result1).toEqual(testData1);
1614+
1615+
// Test case 2: Dropdown field with options
1616+
const testData2 = {
1617+
app_id: 820,
1618+
properties: {
1619+
status: {
1620+
type: 'DROP_DOWN',
1621+
code: 'status',
1622+
label: 'Status',
1623+
options: {
1624+
'Not Started': {
1625+
label: 'Not Started',
1626+
index: '0',
1627+
},
1628+
'In Progress': {
1629+
label: 'In Progress',
1630+
index: '1',
1631+
},
1632+
},
1633+
},
1634+
},
1635+
};
1636+
1637+
const result2 = zodSchema?.parse(testData2);
1638+
expect(result2).toEqual(testData2);
1639+
1640+
// Test case 3: Multiple fields
1641+
const testData3 = {
1642+
app_id: 123,
1643+
properties: {
1644+
number_field: {
1645+
type: 'NUMBER',
1646+
code: 'number_field',
1647+
label: '数値フィールド',
1648+
},
1649+
text_field: {
1650+
type: 'SINGLE_LINE_TEXT',
1651+
code: 'text_field',
1652+
label: 'テキストフィールド',
1653+
},
1654+
},
1655+
};
1656+
1657+
const result3 = zodSchema?.parse(testData3);
1658+
expect(result3).toEqual(testData3);
1659+
});
1660+
1661+
it('should handle the actual reported failing case', () => {
1662+
// This is the exact schema that's failing for the user
1663+
const schema: JsonSchemaType = {
1664+
type: 'object',
1665+
properties: {
1666+
app_id: {
1667+
type: 'number',
1668+
description: 'アプリID',
1669+
},
1670+
properties: {
1671+
type: 'object',
1672+
description: 'フィールドの設定(各フィールドには code, type, label の指定が必須)',
1673+
},
1674+
},
1675+
required: ['app_id', 'properties'],
1676+
};
1677+
1678+
const zodSchema = convertWithResolvedRefs(schema);
1679+
1680+
// The exact data the user is trying to send
1681+
const userData = {
1682+
app_id: 810,
1683+
properties: {
1684+
minutes_id: {
1685+
code: 'minutes_id',
1686+
type: 'SINGLE_LINE_TEXT',
1687+
label: 'minutes_id',
1688+
required: false,
1689+
},
1690+
},
1691+
};
1692+
1693+
// WITH THE FIX: The properties now pass through correctly!
1694+
const result = zodSchema?.parse(userData);
1695+
expect(result).toEqual(userData);
1696+
1697+
// This fixes the error "properties requires at least one field definition"
1698+
// The MCP server now receives the full properties object
1699+
});
1700+
1701+
it('should demonstrate fix by treating bare object type as passthrough', () => {
1702+
// Test what happens if we modify the conversion to treat bare object types
1703+
// without properties as passthrough schemas
1704+
const schema: JsonSchemaType = {
1705+
type: 'object',
1706+
properties: {
1707+
app_id: {
1708+
type: 'number',
1709+
description: 'アプリID',
1710+
},
1711+
properties: {
1712+
type: 'object',
1713+
description: 'フィールドの設定(各フィールドには code, type, label の指定が必須)',
1714+
},
1715+
},
1716+
required: ['app_id', 'properties'],
1717+
};
1718+
1719+
// For now, we'll simulate the fix by adding additionalProperties
1720+
const fixedSchema: JsonSchemaType = {
1721+
...schema,
1722+
properties: {
1723+
...schema.properties,
1724+
properties: {
1725+
...(schema.properties!.properties as JsonSchemaType),
1726+
additionalProperties: true,
1727+
},
1728+
},
1729+
};
1730+
1731+
const zodSchema = convertWithResolvedRefs(fixedSchema);
1732+
1733+
const userData = {
1734+
app_id: 810,
1735+
properties: {
1736+
minutes_id: {
1737+
code: 'minutes_id',
1738+
type: 'SINGLE_LINE_TEXT',
1739+
label: 'minutes_id',
1740+
required: false,
1741+
},
1742+
},
1743+
};
1744+
1745+
const result = zodSchema?.parse(userData);
1746+
expect(result).toEqual(userData);
1747+
});
1748+
1749+
it('should NOT treat object schemas with $ref or complex properties as bare objects', () => {
1750+
// This test ensures our fix doesn't affect schemas with $ref or other complex structures
1751+
const schemaWithRef = {
1752+
type: 'object' as const,
1753+
properties: {
1754+
data: {
1755+
type: 'object' as const,
1756+
// This has anyOf with $ref - should NOT be treated as a bare object
1757+
anyOf: [{ $ref: '#/$defs/dataSchema' }, { type: 'null' as const }],
1758+
},
1759+
},
1760+
$defs: {
1761+
dataSchema: {
1762+
type: 'object' as const,
1763+
additionalProperties: {
1764+
type: 'string' as const,
1765+
},
1766+
},
1767+
},
1768+
};
1769+
1770+
// Convert without resolving refs
1771+
const zodSchema = convertJsonSchemaToZod(schemaWithRef as any, {
1772+
transformOneOfAnyOf: true,
1773+
});
1774+
1775+
const testData = {
1776+
data: {
1777+
field1: 'value1',
1778+
field2: 'value2',
1779+
},
1780+
};
1781+
1782+
// Without ref resolution, the data field should be stripped/empty
1783+
const result = zodSchema?.parse(testData);
1784+
expect(result?.data).toEqual({});
1785+
});
1786+
1787+
it('should NOT treat object schemas with oneOf/anyOf as bare objects', () => {
1788+
// Ensure schemas with oneOf/anyOf are not treated as bare objects
1789+
const schemaWithOneOf = {
1790+
type: 'object' as const,
1791+
properties: {
1792+
config: {
1793+
type: 'object' as const,
1794+
// Empty properties but has oneOf - should NOT be passthrough
1795+
oneOf: [
1796+
{ properties: { type: { const: 'A' } } },
1797+
{ properties: { type: { const: 'B' } } },
1798+
],
1799+
} as any,
1800+
},
1801+
};
1802+
1803+
const zodSchema = convertWithResolvedRefs(schemaWithOneOf as any, {
1804+
transformOneOfAnyOf: true,
1805+
});
1806+
1807+
const testData = {
1808+
config: {
1809+
randomField: 'should not pass through',
1810+
},
1811+
};
1812+
1813+
// The random field should be stripped because this isn't a bare object
1814+
const result = zodSchema?.parse(testData);
1815+
expect(result?.config).toEqual({});
1816+
});
1817+
});
14881818
});

packages/api/src/mcp/zod.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,18 @@ export function convertJsonSchemaToZod(
361361
const shape: Record<string, z.ZodType> = {};
362362
const properties = schema.properties ?? {};
363363

364+
/** Check if this is a bare object schema with no properties defined
365+
and no explicit additionalProperties setting */
366+
const isBareObjectSchema =
367+
Object.keys(properties).length === 0 &&
368+
schema.additionalProperties === undefined &&
369+
!schema.patternProperties &&
370+
!schema.propertyNames &&
371+
!schema.$ref &&
372+
!schema.allOf &&
373+
!schema.anyOf &&
374+
!schema.oneOf;
375+
364376
for (const [key, value] of Object.entries(properties)) {
365377
// Handle nested oneOf/anyOf if transformOneOfAnyOf is enabled
366378
if (transformOneOfAnyOf) {
@@ -436,8 +448,9 @@ export function convertJsonSchemaToZod(
436448
}
437449

438450
// Handle additionalProperties for open-ended objects
439-
if (schema.additionalProperties === true) {
451+
if (schema.additionalProperties === true || isBareObjectSchema) {
440452
// This allows any additional properties with any type
453+
// Bare object schemas are treated as passthrough to allow dynamic properties
441454
zodSchema = objectSchema.passthrough();
442455
} else if (typeof schema.additionalProperties === 'object') {
443456
// For specific additional property types

0 commit comments

Comments
 (0)