Skip to content

Commit 7e20b5c

Browse files
chore(plugin-mcp): improve schema sanitization and test coverage (#15704)
- Improve Schema validation — verifies that the MCP tools/list response generates correct input schemas for each field type (correct type, enum values, nested properties, layout field flattening, etc.) - Adds a new FieldTypes collection test suite covering the full range of Payload field types over MCP
1 parent 8228a7d commit 7e20b5c

File tree

5 files changed

+1298
-18
lines changed

5 files changed

+1298
-18
lines changed

packages/plugin-mcp/src/utils/schemaConversion/sanitizeJsonSchema.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import type { JSONSchema4 } from 'json-schema'
44
* Removes internal Payload properties (id, createdAt, updatedAt) from a
55
* JSON Schema so they don't appear in the generated Zod validation schema.
66
* Also strips `id` from the `required` array when present.
7+
*
8+
* Additionally normalizes nullable type arrays (e.g. `['array', 'null']` →
9+
* `'array'`) throughout the schema tree. Without this, `json-schema-to-zod`
10+
* emits a Zod union which the MCP SDK serialises back as `anyOf`, stripping
11+
* the concrete `type` from the output and breaking schema introspection.
712
*/
813
export function sanitizeJsonSchema(schema: JSONSchema4): JSONSchema4 {
914
delete schema?.properties?.id
@@ -17,5 +22,41 @@ export function sanitizeJsonSchema(schema: JSONSchema4): JSONSchema4 {
1722
}
1823
}
1924

25+
if (schema.properties && typeof schema.properties === 'object') {
26+
for (const key of Object.keys(schema.properties)) {
27+
const prop = schema.properties[key] as JSONSchema4
28+
if (!prop || typeof prop !== 'object') {
29+
continue
30+
}
31+
normalizeNullableType(prop)
32+
if (prop.properties) {
33+
sanitizeJsonSchema(prop)
34+
}
35+
if (prop.items && typeof prop.items === 'object' && !Array.isArray(prop.items)) {
36+
sanitizeJsonSchema(prop.items)
37+
}
38+
}
39+
}
40+
2041
return schema
2142
}
43+
44+
/**
45+
* Strips `'null'` from a `type` array only when the remaining type is a
46+
* complex structural type (`array` or `object`).
47+
*
48+
* Simple scalar types (`string`, `number`, `boolean`) are intentionally
49+
* preserved as `['string', 'null']` so that the MCP SDK serialises them as a
50+
* compact inline `type` array. Complex types however cause `zodToJsonSchema`
51+
* to emit `anyOf: [{ type: 'array', items: ... }, { type: 'null' }]`, which
52+
* has no top-level `type` property and breaks schema introspection by clients.
53+
*/
54+
function normalizeNullableType(schema: JSONSchema4): void {
55+
if (!Array.isArray(schema.type)) {
56+
return
57+
}
58+
const nonNullTypes = schema.type.filter((t) => t !== 'null')
59+
if (nonNullTypes.length === 1 && (nonNullTypes[0] === 'array' || nonNullTypes[0] === 'object')) {
60+
schema.type = nonNullTypes[0]
61+
}
62+
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
export const fieldTypesSlug = 'field-types'
4+
5+
export const FieldTypes: CollectionConfig = {
6+
slug: fieldTypesSlug,
7+
fields: [
8+
// Atomic data fields
9+
{
10+
name: 'textField',
11+
type: 'text',
12+
admin: {
13+
description: 'A simple text field',
14+
},
15+
},
16+
{
17+
name: 'textareaField',
18+
type: 'textarea',
19+
admin: {
20+
description: 'A textarea field',
21+
},
22+
},
23+
{
24+
name: 'numberField',
25+
type: 'number',
26+
admin: {
27+
description: 'A number field',
28+
},
29+
},
30+
{
31+
name: 'emailField',
32+
type: 'email',
33+
admin: {
34+
description: 'An email field',
35+
},
36+
},
37+
{
38+
name: 'checkboxField',
39+
type: 'checkbox',
40+
admin: {
41+
description: 'A checkbox field',
42+
},
43+
},
44+
{
45+
name: 'dateField',
46+
type: 'date',
47+
admin: {
48+
description: 'A date field',
49+
},
50+
},
51+
{
52+
name: 'codeField',
53+
type: 'code',
54+
admin: {
55+
description: 'A code field',
56+
},
57+
},
58+
{
59+
name: 'jsonField',
60+
type: 'json',
61+
admin: {
62+
description: 'A JSON field',
63+
},
64+
},
65+
{
66+
name: 'selectField',
67+
type: 'select',
68+
admin: {
69+
description: 'A select field',
70+
},
71+
options: [
72+
{ label: 'Option One', value: 'option1' },
73+
{ label: 'Option Two', value: 'option2' },
74+
{ label: 'Option Three', value: 'option3' },
75+
],
76+
},
77+
{
78+
name: 'radioField',
79+
type: 'radio',
80+
admin: {
81+
description: 'A radio field',
82+
},
83+
options: [
84+
{ label: 'Radio One', value: 'radio1' },
85+
{ label: 'Radio Two', value: 'radio2' },
86+
{ label: 'Radio Three', value: 'radio3' },
87+
],
88+
},
89+
90+
// Array field
91+
{
92+
name: 'arrayField',
93+
type: 'array',
94+
admin: {
95+
description: 'An array field with nested items',
96+
},
97+
fields: [
98+
{
99+
name: 'item',
100+
type: 'text',
101+
},
102+
{
103+
name: 'itemNumber',
104+
type: 'number',
105+
},
106+
],
107+
},
108+
109+
// Group field (stored as nested object)
110+
{
111+
name: 'groupField',
112+
type: 'group',
113+
admin: {
114+
description: 'A group field with nested properties',
115+
},
116+
fields: [
117+
{
118+
name: 'groupText',
119+
type: 'text',
120+
},
121+
{
122+
name: 'groupNumber',
123+
type: 'number',
124+
},
125+
],
126+
},
127+
128+
// Upload field
129+
{
130+
name: 'uploadField',
131+
type: 'upload',
132+
admin: {
133+
description: 'An upload field',
134+
},
135+
relationTo: 'media',
136+
},
137+
138+
// UI field (display-only, not stored, should be absent from create/update schema)
139+
{
140+
name: 'uiField',
141+
type: 'ui',
142+
admin: {
143+
components: {},
144+
},
145+
},
146+
147+
// Collapsible layout field (children are flattened to top level in the document)
148+
{
149+
type: 'collapsible',
150+
label: 'Collapsible Section',
151+
fields: [
152+
{
153+
name: 'collapsibleText',
154+
type: 'text',
155+
admin: {
156+
description: 'Text field inside a collapsible container',
157+
},
158+
},
159+
],
160+
},
161+
162+
// Row layout field (children are flattened to top level in the document)
163+
{
164+
type: 'row',
165+
fields: [
166+
{
167+
name: 'rowText',
168+
type: 'text',
169+
admin: {
170+
description: 'Text field inside a row container',
171+
},
172+
},
173+
],
174+
},
175+
176+
// Tabs field with both named and unnamed tabs
177+
{
178+
type: 'tabs',
179+
tabs: [
180+
// Named tab - stored as a nested object at `namedTab.namedTabText`
181+
{
182+
name: 'namedTab',
183+
label: 'Named Tab',
184+
fields: [
185+
{
186+
name: 'namedTabText',
187+
type: 'text',
188+
admin: {
189+
description: 'Text field inside a named tab',
190+
},
191+
},
192+
],
193+
},
194+
// Unnamed tab - children are flattened to the top level of the document
195+
{
196+
label: 'Unnamed Tab',
197+
fields: [
198+
{
199+
name: 'unnamedTabText',
200+
type: 'text',
201+
admin: {
202+
description: 'Text field inside an unnamed tab',
203+
},
204+
},
205+
],
206+
},
207+
],
208+
},
209+
],
210+
}

test/plugin-mcp/config.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'url'
55
import { z } from 'zod'
66

77
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
8+
import { FieldTypes } from './collections/FieldTypes.js'
89
import { Media } from './collections/Media.js'
910
import { ModifiedPrompts } from './collections/ModifiedPrompts.js'
1011
import { Pages } from './collections/Pages.js'
@@ -27,7 +28,17 @@ export default buildConfigWithDefaults({
2728
baseDir: path.resolve(dirname),
2829
},
2930
},
30-
collections: [Users, Media, Posts, Products, Rolls, ModifiedPrompts, ReturnedResources, Pages],
31+
collections: [
32+
Users,
33+
Media,
34+
Posts,
35+
Products,
36+
Rolls,
37+
ModifiedPrompts,
38+
ReturnedResources,
39+
Pages,
40+
FieldTypes,
41+
],
3142
localization: {
3243
defaultLocale: 'en',
3344
fallback: true,
@@ -96,6 +107,15 @@ export default buildConfigWithDefaults({
96107
[Products.slug]: {
97108
enabled: true,
98109
},
110+
'field-types': {
111+
enabled: {
112+
find: true,
113+
create: true,
114+
update: true,
115+
delete: true,
116+
},
117+
description: 'A collection covering all Payload field types for MCP schema testing.',
118+
},
99119
pages: {
100120
enabled: {
101121
find: true,

0 commit comments

Comments
 (0)