diff --git a/docs/plugins/mcp.mdx b/docs/plugins/mcp.mdx
index 120105031bf..426e3c3e74a 100644
--- a/docs/plugins/mcp.mdx
+++ b/docs/plugins/mcp.mdx
@@ -112,6 +112,94 @@ export default config
| `mcp.serverOptions.serverInfo.name` | `string` | The name of the MCP server (default: 'Payload MCP Server'). |
| `mcp.serverOptions.serverInfo.version` | `string` | The version of the MCP server (default: '1.0.0'). |
+## Connecting to MCP Clients
+
+After installing and configuring the plugin, you can connect apps with MCP client capabilities to Payload.
+
+### Step 1: Create an API Key
+
+1. Start your Payload server
+2. Navigate to your admin panel at `http://localhost:3000/admin`
+3. Go to the **MCP → API Keys** collection
+4. Click **Create New**
+5. Allow or Disallow MCP traffic permissions for each collection (enable find, create, update, delete as needed)
+6. Click **Create** and copy the uniquely generated API key
+
+### Step 2: Configure Your MCP Client
+
+MCP Clients can be configured to interact with your MCP server.
+These clients require some JSON configuration, or platform configuration in order to know how to reach your MCP server.
+
+
+ Caution: the format of these JSON files may change over time. Please check the
+ client website for updates.
+
+
+Our recommended approach to make your server available for most MCP clients is to use the [mcp-remote](https://www.npmjs.com/package/mcp-remote) package via `npx`.
+
+Below are configuration examples for popular MCP clients.
+
+#### [VSCode](https://code.visualstudio.com/docs/copilot/customization/mcp-servers)
+
+```json
+{
+ "mcp.servers": {
+ "Payload": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "mcp-remote",
+ "http://127.0.0.1:3000/api/mcp",
+ "--header",
+ "Authorization: Bearer API-KEY-HERE"
+ ]
+ }
+ }
+}
+```
+
+#### [Cursor](https://cursor.com/docs/context/mcp)
+
+```json
+{
+ "mcpServers": {
+ "Payload": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "mcp-remote",
+ "http://localhost:3000/api/mcp",
+ "--header",
+ "Authorization: Bearer API-KEY-HERE"
+ ]
+ }
+ }
+}
+```
+
+#### Other MCP Clients
+
+For connections without using `mcp-remote` you can use this configuration format:
+
+```json
+{
+ "mcpServers": {
+ "Payload": {
+ "type": "http",
+ "url": "http://localhost:3000/api/mcp",
+ "headers": {
+ "Authorization": "Bearer API-KEY-HERE"
+ }
+ }
+ }
+}
+```
+
+## Customizations
+
+The plugin supports fully custom `prompts`, `tools` and `resources` that can be called or retrieved by MCP clients.
+After defining a custom method you can allow / disallow the feature from the admin panel by adjusting the `API Key` MCP Options checklist.
+
## Prompts
Prompts allow models to generate structured messages for specific tasks. Each prompt defines a schema for arguments and returns formatted messages:
@@ -126,7 +214,7 @@ prompts: [
content: z.string().describe('The content to review'),
criteria: z.array(z.string()).describe('Review criteria'),
},
- handler: ({ content, criteria }) => ({
+ handler: ({ content, criteria }, req) => ({
messages: [
{
content: {
@@ -154,6 +242,7 @@ resources: [
description: 'Company content creation guidelines',
uri: 'guidelines://company',
mimeType: 'text/markdown',
+ handler: (uri, req) => ({
handler: (uri, req) => ({
contents: [
{
@@ -198,7 +287,6 @@ tools: [
description: 'Get useful scores about content in posts',
handler: async (args, req) => {
const { payload } = req
-
const stats = await payload.find({
collection: 'posts',
where: {
@@ -249,6 +337,12 @@ mcpPlugin({
{ label: 'Marketing', value: 'marketing' },
],
})
+
+ // You can also add hooks
+ collection.hooks?.beforeRead?.push(({ doc, req }) => {
+ req.payload.logger.info('Before Read MCP hook!')
+ return doc
+ })
return collection
},
// ... other options
@@ -363,43 +457,3 @@ const config = buildConfig({
],
})
```
-
-## MCP Clients
-
-MCP Clients can be configured to interact with your MCP server. These clients require some JSON configuration, or platform configuration in order to know how to reach your MCP server.
-
-> Caution: the format of these JSON files may change over time. Please check the client website for updates.
-
-[VSCode](https://code.visualstudio.com/docs/copilot/customization/mcp-servers)
-
-```json
-{
- "mcp.servers": {
- "Payload": {
- "url": "http://localhost:3000/api/mcp",
- "headers": {
- "Authorization": "Bearer API-KEY-HERE"
- }
- }
- }
-}
-```
-
-[Cursor](https://cursor.com/docs/context/mcp)
-
-```json
-{
- "mcpServers": {
- "Payload": {
- "command": "npx",
- "args": [
- "-y",
- "mcp-remote",
- "http://localhost:3000/api/mcp",
- "--header",
- "Authorization: Bearer API-KEY-HERE"
- ]
- }
- }
-}
-```
diff --git a/packages/plugin-mcp/src/mcp/tools/resource/create.ts b/packages/plugin-mcp/src/mcp/tools/resource/create.ts
index 78e2b16749f..3c2e8e47a32 100644
--- a/packages/plugin-mcp/src/mcp/tools/resource/create.ts
+++ b/packages/plugin-mcp/src/mcp/tools/resource/create.ts
@@ -2,6 +2,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { JSONSchema4 } from 'json-schema'
import type { PayloadRequest, TypedUser } from 'payload'
+import { z } from 'zod'
+
import type { PluginMCPServerConfig } from '../../../types.js'
import { toCamelCase } from '../../../utils/camelCase.js'
@@ -18,6 +20,9 @@ export const createResourceTool = (
) => {
const tool = async (
data: string,
+ draft: boolean,
+ locale?: string,
+ fallbackLocale?: string,
): Promise<{
content: Array<{
text: string
@@ -27,7 +32,9 @@ export const createResourceTool = (
const payload = req.payload
if (verboseLogs) {
- payload.logger.info(`[payload-mcp] Creating resource in collection: ${collectionSlug}`)
+ payload.logger.info(
+ `[payload-mcp] Creating resource in collection: ${collectionSlug}${locale ? ` with locale: ${locale}` : ''}`,
+ )
}
try {
@@ -51,9 +58,12 @@ export const createResourceTool = (
const result = await payload.create({
collection: collectionSlug,
data: parsedData,
+ draft,
overrideAccess: false,
req,
user,
+ ...(locale && { locale }),
+ ...(fallbackLocale && { fallbackLocale }),
})
if (verboseLogs) {
@@ -109,13 +119,39 @@ ${JSON.stringify(result, null, 2)}
if (collections?.[collectionSlug]?.enabled) {
const convertedFields = convertCollectionSchemaToZod(schema)
+ // Create a new schema that combines the converted fields with create-specific parameters
+ const createResourceSchema = z.object({
+ ...convertedFields.shape,
+ draft: z
+ .boolean()
+ .optional()
+ .default(false)
+ .describe('Whether to create the document as a draft'),
+ fallbackLocale: z
+ .string()
+ .optional()
+ .describe('Optional: fallback locale code to use when requested locale is not available'),
+ locale: z
+ .string()
+ .optional()
+ .describe(
+ 'Optional: locale code to create the document in (e.g., "en", "es"). Defaults to the default locale',
+ ),
+ })
+
server.tool(
`create${collectionSlug.charAt(0).toUpperCase() + toCamelCase(collectionSlug).slice(1)}`,
`${collections?.[collectionSlug]?.description || toolSchemas.createResource.description.trim()}`,
- convertedFields.shape,
+ createResourceSchema.shape,
async (params: Record) => {
- const data = JSON.stringify(params)
- return await tool(data)
+ const { draft, fallbackLocale, locale, ...fieldData } = params
+ const data = JSON.stringify(fieldData)
+ return await tool(
+ data,
+ draft as boolean,
+ locale as string | undefined,
+ fallbackLocale as string | undefined,
+ )
},
)
}
diff --git a/packages/plugin-mcp/src/mcp/tools/resource/delete.ts b/packages/plugin-mcp/src/mcp/tools/resource/delete.ts
index 9f0979ee624..2363fff31a4 100644
--- a/packages/plugin-mcp/src/mcp/tools/resource/delete.ts
+++ b/packages/plugin-mcp/src/mcp/tools/resource/delete.ts
@@ -15,9 +15,11 @@ export const deleteResourceTool = (
collections: PluginMCPServerConfig['collections'],
) => {
const tool = async (
- id?: string,
+ id?: number | string,
where?: string,
depth: number = 0,
+ locale?: string,
+ fallbackLocale?: string,
): Promise<{
content: Array<{
text: string
@@ -28,7 +30,7 @@ export const deleteResourceTool = (
if (verboseLogs) {
payload.logger.info(
- `[payload-mcp] Deleting resource from collection: ${collectionSlug}${id ? ` with ID: ${id}` : ' with where clause'}`,
+ `[payload-mcp] Deleting resource from collection: ${collectionSlug}${id ? ` with ID: ${id}` : ' with where clause'}${locale ? `, locale: ${locale}` : ''}`,
)
}
@@ -80,6 +82,8 @@ export const deleteResourceTool = (
overrideAccess: false,
req,
user,
+ ...(locale && { locale }),
+ ...(fallbackLocale && { fallbackLocale }),
}
// Delete by ID or where clause
@@ -204,8 +208,8 @@ ${JSON.stringify(errors, null, 2)}
`delete${collectionSlug.charAt(0).toUpperCase() + toCamelCase(collectionSlug).slice(1)}`,
`${collections?.[collectionSlug]?.description || toolSchemas.deleteResource.description.trim()}`,
toolSchemas.deleteResource.parameters.shape,
- async ({ id, depth, where }) => {
- return await tool(id, where, depth)
+ async ({ id, depth, fallbackLocale, locale, where }) => {
+ return await tool(id, where, depth, locale, fallbackLocale)
},
)
}
diff --git a/packages/plugin-mcp/src/mcp/tools/resource/find.ts b/packages/plugin-mcp/src/mcp/tools/resource/find.ts
index 4a3b55af89f..20adf994d77 100644
--- a/packages/plugin-mcp/src/mcp/tools/resource/find.ts
+++ b/packages/plugin-mcp/src/mcp/tools/resource/find.ts
@@ -15,11 +15,13 @@ export const findResourceTool = (
collections: PluginMCPServerConfig['collections'],
) => {
const tool = async (
- id?: string,
+ id?: number | string,
limit: number = 10,
page: number = 1,
sort?: string,
where?: string,
+ locale?: string,
+ fallbackLocale?: string,
): Promise<{
content: Array<{
text: string
@@ -30,7 +32,7 @@ export const findResourceTool = (
if (verboseLogs) {
payload.logger.info(
- `[payload-mcp] Reading resource from collection: ${collectionSlug}${id ? ` with ID: ${id}` : ''}, limit: ${limit}, page: ${page}`,
+ `[payload-mcp] Reading resource from collection: ${collectionSlug}${id ? ` with ID: ${id}` : ''}, limit: ${limit}, page: ${page}${locale ? `, locale: ${locale}` : ''}`,
)
}
@@ -67,6 +69,8 @@ export const findResourceTool = (
overrideAccess: false,
req,
user,
+ ...(locale && { locale }),
+ ...(fallbackLocale && { fallbackLocale }),
})
if (verboseLogs) {
@@ -120,6 +124,8 @@ ${JSON.stringify(doc, null, 2)}`,
page,
req,
user,
+ ...(locale && { locale }),
+ ...(fallbackLocale && { fallbackLocale }),
}
if (sort) {
@@ -190,8 +196,8 @@ Page: ${result.page} of ${result.totalPages}
`find${collectionSlug.charAt(0).toUpperCase() + toCamelCase(collectionSlug).slice(1)}`,
`${collections?.[collectionSlug]?.description || toolSchemas.findResources.description.trim()}`,
toolSchemas.findResources.parameters.shape,
- async ({ id, limit, page, sort, where }) => {
- return await tool(id, limit, page, sort, where)
+ async ({ id, fallbackLocale, limit, locale, page, sort, where }) => {
+ return await tool(id, limit, page, sort, where, locale, fallbackLocale)
},
)
}
diff --git a/packages/plugin-mcp/src/mcp/tools/resource/update.ts b/packages/plugin-mcp/src/mcp/tools/resource/update.ts
index 2613f3a7860..c9b8ff51abd 100644
--- a/packages/plugin-mcp/src/mcp/tools/resource/update.ts
+++ b/packages/plugin-mcp/src/mcp/tools/resource/update.ts
@@ -20,13 +20,15 @@ export const updateResourceTool = (
) => {
const tool = async (
data: string,
- id?: string,
+ id?: number | string,
where?: string,
draft: boolean = false,
depth: number = 0,
overrideLock: boolean = true,
filePath?: string,
overwriteExistingFiles: boolean = false,
+ locale?: string,
+ fallbackLocale?: string,
): Promise<{
content: Array<{
text: string
@@ -37,7 +39,7 @@ export const updateResourceTool = (
if (verboseLogs) {
payload.logger.info(
- `[payload-mcp] Updating resource in collection: ${collectionSlug}${id ? ` with ID: ${id}` : ' with where clause'}, draft: ${draft}`,
+ `[payload-mcp] Updating resource in collection: ${collectionSlug}${id ? ` with ID: ${id}` : ' with where clause'}, draft: ${draft}${locale ? `, locale: ${locale}` : ''}`,
)
}
@@ -120,6 +122,8 @@ export const updateResourceTool = (
user,
...(filePath && { filePath }),
...(overwriteExistingFiles && { overwriteExistingFiles }),
+ ...(locale && { locale }),
+ ...(fallbackLocale && { fallbackLocale }),
}
if (verboseLogs) {
@@ -168,6 +172,8 @@ ${JSON.stringify(result, null, 2)}
where: whereClause,
...(filePath && { filePath }),
...(overwriteExistingFiles && { overwriteExistingFiles }),
+ ...(locale && { locale }),
+ ...(fallbackLocale && { fallbackLocale }),
}
if (verboseLogs) {
@@ -255,9 +261,10 @@ ${JSON.stringify(errors, null, 2)}
const convertedFields = convertCollectionSchemaToZod(schema)
// Create a new schema that combines the converted fields with update-specific parameters
+ // Use .partial() to make all fields optional for partial updates
const updateResourceSchema = z.object({
- ...convertedFields.shape,
- id: z.string().optional().describe('The ID of the document to update'),
+ ...convertedFields.partial().shape,
+ id: z.union([z.string(), z.number()]).optional().describe('The ID of the document to update'),
depth: z
.number()
.optional()
@@ -268,7 +275,17 @@ ${JSON.stringify(errors, null, 2)}
.optional()
.default(false)
.describe('Whether to update the document as a draft'),
+ fallbackLocale: z
+ .string()
+ .optional()
+ .describe('Optional: fallback locale code to use when requested locale is not available'),
filePath: z.string().optional().describe('File path for file uploads'),
+ locale: z
+ .string()
+ .optional()
+ .describe(
+ 'Optional: locale code to update the document in (e.g., "en", "es"). Defaults to the default locale',
+ ),
overrideLock: z
.boolean()
.optional()
@@ -294,7 +311,9 @@ ${JSON.stringify(errors, null, 2)}
id,
depth,
draft,
+ fallbackLocale,
filePath,
+ locale,
overrideLock,
overwriteExistingFiles,
where,
@@ -304,13 +323,15 @@ ${JSON.stringify(errors, null, 2)}
const data = JSON.stringify(fieldData)
return await tool(
data,
- id as string | undefined,
+ id as number | string | undefined,
where as string | undefined,
draft as boolean,
depth as number,
overrideLock as boolean,
filePath as string | undefined,
overwriteExistingFiles as boolean,
+ locale as string | undefined,
+ fallbackLocale as string | undefined,
)
},
)
diff --git a/packages/plugin-mcp/src/mcp/tools/schemas.ts b/packages/plugin-mcp/src/mcp/tools/schemas.ts
index 15c2e9264a3..3f48504088e 100644
--- a/packages/plugin-mcp/src/mcp/tools/schemas.ts
+++ b/packages/plugin-mcp/src/mcp/tools/schemas.ts
@@ -5,11 +5,15 @@ export const toolSchemas = {
description: 'Find documents in a collection by ID or where clause using Find or FindByID.',
parameters: z.object({
id: z
- .string()
+ .union([z.string(), z.number()])
.optional()
.describe(
'Optional: specific document ID to retrieve. If not provided, returns all documents',
),
+ fallbackLocale: z
+ .string()
+ .optional()
+ .describe('Optional: fallback locale code to use when requested locale is not available'),
limit: z
.number()
.int()
@@ -18,6 +22,12 @@ export const toolSchemas = {
.optional()
.default(10)
.describe('Maximum number of documents to return (default: 10, max: 100)'),
+ locale: z
+ .string()
+ .optional()
+ .describe(
+ 'Optional: locale code to retrieve data in (e.g., "en", "es"). Use "all" to retrieve all locales for localized fields',
+ ),
page: z
.number()
.int()
@@ -47,13 +57,26 @@ export const toolSchemas = {
.optional()
.default(false)
.describe('Whether to create the document as a draft'),
+ fallbackLocale: z
+ .string()
+ .optional()
+ .describe('Optional: fallback locale code to use when requested locale is not available'),
+ locale: z
+ .string()
+ .optional()
+ .describe(
+ 'Optional: locale code to create the document in (e.g., "en", "es"). Defaults to the default locale',
+ ),
}),
},
updateResource: {
description: 'Update documents in a collection by ID or where clause.',
parameters: z.object({
- id: z.string().optional().describe('Optional: specific document ID to update'),
+ id: z
+ .union([z.string(), z.number()])
+ .optional()
+ .describe('Optional: specific document ID to update'),
data: z.string().describe('JSON string containing the data to update'),
depth: z
.number()
@@ -64,7 +87,17 @@ export const toolSchemas = {
.default(0)
.describe('Depth of population for relationships'),
draft: z.boolean().optional().default(false).describe('Whether to update as a draft'),
+ fallbackLocale: z
+ .string()
+ .optional()
+ .describe('Optional: fallback locale code to use when requested locale is not available'),
filePath: z.string().optional().describe('Optional: absolute file path for file uploads'),
+ locale: z
+ .string()
+ .optional()
+ .describe(
+ 'Optional: locale code to update the document in (e.g., "en", "es"). Defaults to the default locale',
+ ),
overrideLock: z
.boolean()
.optional()
@@ -85,7 +118,10 @@ export const toolSchemas = {
deleteResource: {
description: 'Delete documents in a collection by ID or where clause.',
parameters: z.object({
- id: z.string().optional().describe('Optional: specific document ID to delete'),
+ id: z
+ .union([z.string(), z.number()])
+ .optional()
+ .describe('Optional: specific document ID to delete'),
depth: z
.number()
.int()
@@ -94,6 +130,16 @@ export const toolSchemas = {
.optional()
.default(0)
.describe('Depth of population for relationships in response'),
+ fallbackLocale: z
+ .string()
+ .optional()
+ .describe('Optional: fallback locale code to use when requested locale is not available'),
+ locale: z
+ .string()
+ .optional()
+ .describe(
+ 'Optional: locale code for the operation (e.g., "en", "es"). Defaults to the default locale',
+ ),
where: z
.string()
.optional()
diff --git a/test/plugin-mcp/collections/Posts.ts b/test/plugin-mcp/collections/Posts.ts
index d1beb20431d..b6a4c3c48fc 100644
--- a/test/plugin-mcp/collections/Posts.ts
+++ b/test/plugin-mcp/collections/Posts.ts
@@ -6,6 +6,7 @@ export const Posts: CollectionConfig = {
{
name: 'title',
type: 'text',
+ localized: true,
admin: {
description: 'The title of the post',
},
@@ -14,6 +15,7 @@ export const Posts: CollectionConfig = {
{
name: 'content',
type: 'text',
+ localized: true,
admin: {
description: 'The content of the post',
},
@@ -32,7 +34,16 @@ export const Posts: CollectionConfig = {
beforeRead: [
({ doc, req }) => {
if (req.payloadAPI === 'MCP') {
- doc.title = `${doc.title} (MCP Hook Override)`
+ // Handle both localized (object) and non-localized (string) title
+ if (typeof doc.title === 'object' && doc.title !== null) {
+ // Localized field - update all locale values
+ Object.keys(doc.title).forEach((locale) => {
+ doc.title[locale] = `${doc.title[locale]} (MCP Hook Override)`
+ })
+ } else if (typeof doc.title === 'string') {
+ // Non-localized field
+ doc.title = `${doc.title} (MCP Hook Override)`
+ }
}
return doc
},
diff --git a/test/plugin-mcp/config.ts b/test/plugin-mcp/config.ts
index ce4b51f259d..deee021f0d5 100644
--- a/test/plugin-mcp/config.ts
+++ b/test/plugin-mcp/config.ts
@@ -24,6 +24,24 @@ export default buildConfigWithDefaults({
},
},
collections: [Users, Media, Posts, Products, Rolls, ModifiedPrompts, ReturnedResources],
+ localization: {
+ defaultLocale: 'en',
+ fallback: true,
+ locales: [
+ {
+ code: 'en',
+ label: 'English',
+ },
+ {
+ code: 'es',
+ label: 'Spanish',
+ },
+ {
+ code: 'fr',
+ label: 'French',
+ },
+ ],
+ },
onInit: seed,
plugins: [
mcpPlugin({
@@ -77,6 +95,8 @@ export default buildConfigWithDefaults({
enabled: {
find: true,
create: true,
+ update: true,
+ delete: true,
},
description: 'This is a Payload collection with Post documents.',
overrideResponse: (response, doc, req) => {
diff --git a/test/plugin-mcp/int.spec.ts b/test/plugin-mcp/int.spec.ts
index 0d12dbc0e81..f1620560b44 100644
--- a/test/plugin-mcp/int.spec.ts
+++ b/test/plugin-mcp/int.spec.ts
@@ -49,13 +49,14 @@ async function parseStreamResponse(response: Response): Promise {
}
}
-const getApiKey = async (): Promise => {
+const getApiKey = async (enableUpdate = false, enableDelete = false): Promise => {
const doc = await payload.create({
collection: 'payload-mcp-api-keys',
data: {
enableAPIKey: true,
label: 'Test API Key',
- posts: { find: true, create: true },
+ // @ts-expect-error - update is not a valid property
+ posts: { find: true, create: true, update: enableUpdate, delete: enableDelete },
products: { find: true },
apiKey: randomUUID(),
user: userId,
@@ -79,7 +80,9 @@ describe('@payloadcms/plugin-mcp', () => {
})
.then((res) => res.json())
+ // @ts-expect-error - data is not a valid property
token = data.token
+ // @ts-expect-error - data.user is a valid property
userId = data.user.id
})
@@ -133,9 +136,13 @@ describe('@payloadcms/plugin-mcp', () => {
.then((res) => res.json())
expect(data).toBeDefined()
+ // @ts-expect-error - data is a valid property
expect(data.jsonrpc).toBe('2.0')
+ // @ts-expect-error - data is a valid property
expect(data.error).toBeDefined()
+ // @ts-expect-error - data is a valid property
expect(data.error.code).toBe(-32000)
+ // @ts-expect-error - data is a valid property
expect(data.error.message).toBe('Method not allowed.')
})
@@ -220,6 +227,161 @@ describe('@payloadcms/plugin-mcp', () => {
'Rolls a virtual dice with a specified number of sides',
)
expect(json.result.tools[3].inputSchema).toBeDefined()
+
+ // Input Schemas
+ expect(json.result.tools[0].inputSchema).toBeDefined()
+ expect(json.result.tools[0].inputSchema.required).not.toBeDefined()
+ expect(json.result.tools[0].inputSchema.type).toBe('object')
+ expect(json.result.tools[0].inputSchema.additionalProperties).toBe(false)
+ expect(json.result.tools[0].inputSchema.$schema).toBe('http://json-schema.org/draft-07/schema#')
+ expect(json.result.tools[0].inputSchema.properties).toBeDefined()
+ expect(json.result.tools[0].inputSchema.properties.id).toBeDefined()
+ expect(json.result.tools[0].inputSchema.properties.id.type).toHaveLength(2)
+ expect(json.result.tools[0].inputSchema.properties.id.type[0]).toBe('string')
+ expect(json.result.tools[0].inputSchema.properties.id.type[1]).toBe('number')
+ expect(json.result.tools[0].inputSchema.properties.id.description).toContain(
+ 'Optional: specific document ID to retrieve. If not provided, returns all documents',
+ )
+ expect(json.result.tools[0].inputSchema.properties.fallbackLocale).toBeDefined()
+ expect(json.result.tools[0].inputSchema.properties.fallbackLocale.type).toBe('string')
+ expect(json.result.tools[0].inputSchema.properties.fallbackLocale.description).toContain(
+ 'Optional: fallback locale code to use when requested locale is not available',
+ )
+ expect(json.result.tools[0].inputSchema.properties.limit).toBeDefined()
+ expect(json.result.tools[0].inputSchema.properties.limit.type).toBe('integer')
+ expect(json.result.tools[0].inputSchema.properties.limit.minimum).toBe(1)
+ expect(json.result.tools[0].inputSchema.properties.limit.maximum).toBe(100)
+ expect(json.result.tools[0].inputSchema.properties.limit.default).toBe(10)
+ expect(json.result.tools[0].inputSchema.properties.limit.description).toContain(
+ 'Maximum number of documents to return (default: 10, max: 100)',
+ )
+ expect(json.result.tools[0].inputSchema.properties.locale).toBeDefined()
+ expect(json.result.tools[0].inputSchema.properties.locale.type).toBe('string')
+ expect(json.result.tools[0].inputSchema.properties.locale.description).toContain(
+ 'Optional: locale code to retrieve data in (e.g., "en", "es"). Use "all" to retrieve all locales for localized fields',
+ )
+ expect(json.result.tools[0].inputSchema.properties.page).toBeDefined()
+ expect(json.result.tools[0].inputSchema.properties.page.type).toBe('integer')
+ expect(json.result.tools[0].inputSchema.properties.page.minimum).toBe(1)
+ expect(json.result.tools[0].inputSchema.properties.page.default).toBe(1)
+ expect(json.result.tools[0].inputSchema.properties.page.description).toContain(
+ 'Page number for pagination (default: 1)',
+ )
+ expect(json.result.tools[0].inputSchema.properties.sort).toBeDefined()
+ expect(json.result.tools[0].inputSchema.properties.sort.type).toBe('string')
+ expect(json.result.tools[0].inputSchema.properties.sort.description).toContain(
+ 'Field to sort by (e.g., "createdAt", "-updatedAt" for descending)',
+ )
+ expect(json.result.tools[0].inputSchema.properties.where).toBeDefined()
+ expect(json.result.tools[0].inputSchema.properties.where.type).toBe('string')
+ expect(json.result.tools[0].inputSchema.properties.where.description).toContain(
+ 'Optional JSON string for where clause filtering (e.g., \'{"title": {"contains": "test"}}\')',
+ )
+
+ expect(json.result.tools[1].inputSchema).toBeDefined()
+ expect(json.result.tools[1].inputSchema.required).toBeDefined()
+ expect(json.result.tools[1].inputSchema.required).toHaveLength(1)
+ expect(json.result.tools[1].inputSchema.required[0]).toBe('title')
+ expect(json.result.tools[1].inputSchema.type).toBe('object')
+ expect(json.result.tools[1].inputSchema.additionalProperties).toBe(false)
+ expect(json.result.tools[1].inputSchema.$schema).toBe('http://json-schema.org/draft-07/schema#')
+ expect(json.result.tools[1].inputSchema.properties).toBeDefined()
+ expect(json.result.tools[1].inputSchema.properties.title).toBeDefined()
+ expect(json.result.tools[1].inputSchema.properties.title.type).toBe('string')
+ expect(json.result.tools[1].inputSchema.properties.title.description).toBe(
+ 'The title of the post',
+ )
+ expect(json.result.tools[1].inputSchema.properties.content).toBeDefined()
+ expect(json.result.tools[1].inputSchema.properties.content.type).toHaveLength(2)
+ expect(json.result.tools[1].inputSchema.properties.content.type[0]).toBe('string')
+ expect(json.result.tools[1].inputSchema.properties.content.type[1]).toBe('null')
+ expect(json.result.tools[1].inputSchema.properties.content.description).toBe(
+ 'The content of the post',
+ )
+ expect(json.result.tools[1].inputSchema.properties.author).toBeDefined()
+ expect(json.result.tools[1].inputSchema.properties.author.type).toBe(undefined)
+ expect(json.result.tools[1].inputSchema.properties.author.description).toBe(
+ 'The author of the post',
+ )
+ expect(json.result.tools[1].inputSchema.properties.draft).toBeDefined()
+ expect(json.result.tools[1].inputSchema.properties.draft.type).toBe('boolean')
+ expect(json.result.tools[1].inputSchema.properties.draft.description).toBe(
+ 'Whether to create the document as a draft',
+ )
+ expect(json.result.tools[1].inputSchema.properties.fallbackLocale).toBeDefined()
+ expect(json.result.tools[1].inputSchema.properties.fallbackLocale.type).toBe('string')
+ expect(json.result.tools[1].inputSchema.properties.fallbackLocale.description).toBe(
+ 'Optional: fallback locale code to use when requested locale is not available',
+ )
+ expect(json.result.tools[1].inputSchema.properties.locale).toBeDefined()
+ expect(json.result.tools[1].inputSchema.properties.locale.type).toBe('string')
+ expect(json.result.tools[1].inputSchema.properties.locale.description).toBe(
+ 'Optional: locale code to create the document in (e.g., "en", "es"). Defaults to the default locale',
+ )
+
+ expect(json.result.tools[2].inputSchema).toBeDefined()
+ expect(json.result.tools[2].inputSchema.required).not.toBeDefined()
+ expect(json.result.tools[2].inputSchema.type).toBe('object')
+ expect(json.result.tools[2].inputSchema.additionalProperties).toBe(false)
+ expect(json.result.tools[2].inputSchema.$schema).toBe('http://json-schema.org/draft-07/schema#')
+ expect(json.result.tools[2].inputSchema.properties).toBeDefined()
+ expect(json.result.tools[2].inputSchema.properties.id).toBeDefined()
+ expect(json.result.tools[2].inputSchema.properties.id.type).toHaveLength(2)
+ expect(json.result.tools[2].inputSchema.properties.id.type[0]).toBe('string')
+ expect(json.result.tools[2].inputSchema.properties.id.type[1]).toBe('number')
+ expect(json.result.tools[2].inputSchema.properties.id.description).toContain(
+ 'Optional: specific document ID to retrieve. If not provided, returns all documents',
+ )
+ expect(json.result.tools[2].inputSchema.properties.fallbackLocale).toBeDefined()
+ expect(json.result.tools[2].inputSchema.properties.fallbackLocale.type).toBe('string')
+ expect(json.result.tools[2].inputSchema.properties.fallbackLocale.description).toBe(
+ 'Optional: fallback locale code to use when requested locale is not available',
+ )
+ expect(json.result.tools[2].inputSchema.properties.limit).toBeDefined()
+ expect(json.result.tools[2].inputSchema.properties.limit.type).toBe('integer')
+ expect(json.result.tools[2].inputSchema.properties.limit.minimum).toBe(1)
+ expect(json.result.tools[2].inputSchema.properties.limit.maximum).toBe(100)
+ expect(json.result.tools[2].inputSchema.properties.limit.default).toBe(10)
+ expect(json.result.tools[2].inputSchema.properties.limit.description).toContain(
+ 'Maximum number of documents to return (default: 10, max: 100)',
+ )
+ expect(json.result.tools[2].inputSchema.properties.locale).toBeDefined()
+ expect(json.result.tools[2].inputSchema.properties.locale.type).toBe('string')
+ expect(json.result.tools[2].inputSchema.properties.locale.description).toContain(
+ 'Optional: locale code to retrieve data in (e.g., "en", "es"). Use "all" to retrieve all locales for localized fields',
+ )
+ expect(json.result.tools[2].inputSchema.properties.page).toBeDefined()
+ expect(json.result.tools[2].inputSchema.properties.page.type).toBe('integer')
+ expect(json.result.tools[2].inputSchema.properties.page.minimum).toBe(1)
+ expect(json.result.tools[2].inputSchema.properties.page.default).toBe(1)
+ expect(json.result.tools[2].inputSchema.properties.page.description).toContain(
+ 'Page number for pagination (default: 1)',
+ )
+ expect(json.result.tools[2].inputSchema.properties.sort).toBeDefined()
+ expect(json.result.tools[2].inputSchema.properties.sort.type).toBe('string')
+ expect(json.result.tools[2].inputSchema.properties.sort.description).toContain(
+ 'Field to sort by (e.g., "createdAt", "-updatedAt" for descending)',
+ )
+ expect(json.result.tools[2].inputSchema.properties.where).toBeDefined()
+ expect(json.result.tools[2].inputSchema.properties.where.type).toBe('string')
+ expect(json.result.tools[2].inputSchema.properties.where.description).toContain(
+ 'Optional JSON string for where clause filtering (e.g., \'{"title": {"contains": "test"}}\')',
+ )
+
+ expect(json.result.tools[3].inputSchema).toBeDefined()
+ expect(json.result.tools[3].inputSchema.required).not.toBeDefined()
+ expect(json.result.tools[3].inputSchema.type).toBe('object')
+ expect(json.result.tools[3].inputSchema.additionalProperties).toBe(false)
+ expect(json.result.tools[3].inputSchema.$schema).toBe('http://json-schema.org/draft-07/schema#')
+ expect(json.result.tools[3].inputSchema.properties).toBeDefined()
+ expect(json.result.tools[3].inputSchema.properties.sides).toBeDefined()
+ expect(json.result.tools[3].inputSchema.properties.sides.type).toBe('integer')
+ expect(json.result.tools[3].inputSchema.properties.sides.minimum).toBe(2)
+ expect(json.result.tools[3].inputSchema.properties.sides.maximum).toBe(1000)
+ expect(json.result.tools[3].inputSchema.properties.sides.default).toBe(6)
+ expect(json.result.tools[3].inputSchema.properties.sides.description).toContain(
+ 'Number of sides on the dice (default: 6)',
+ )
})
it('should list resources', async () => {
@@ -597,4 +759,289 @@ describe('@payloadcms/plugin-mcp', () => {
'"title": "Test Post for Finding (MCP Hook Override)"',
)
})
+
+ describe('Localization', () => {
+ it('should include locale parameters in tool schemas', async () => {
+ const apiKey = await getApiKey(true, true)
+ const response = await restClient.POST('/mcp', {
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ Accept: 'application/json, text/event-stream',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ id: 1,
+ jsonrpc: '2.0',
+ method: 'tools/list',
+ params: {},
+ }),
+ })
+
+ const json = await parseStreamResponse(response)
+
+ expect(json.result.tools).toBeDefined()
+
+ // Check createPosts has locale parameters
+ const createTool = json.result.tools.find((t: any) => t.name === 'createPosts')
+ expect(createTool).toBeDefined()
+ expect(createTool.inputSchema.properties.locale).toBeDefined()
+ expect(createTool.inputSchema.properties.locale.type).toBe('string')
+ expect(createTool.inputSchema.properties.locale.description).toContain('locale code')
+ expect(createTool.inputSchema.properties.fallbackLocale).toBeDefined()
+
+ // Check updatePosts has locale parameters
+ const updateTool = json.result.tools.find((t: any) => t.name === 'updatePosts')
+ expect(updateTool).toBeDefined()
+ expect(updateTool.inputSchema.properties.locale).toBeDefined()
+ expect(updateTool.inputSchema.properties.fallbackLocale).toBeDefined()
+
+ // Check findPosts has locale parameters
+ const findTool = json.result.tools.find((t: any) => t.name === 'findPosts')
+ expect(findTool).toBeDefined()
+ expect(findTool.inputSchema.properties.locale).toBeDefined()
+ expect(findTool.inputSchema.properties.fallbackLocale).toBeDefined()
+
+ // Check deletePosts has locale parameters
+ const deleteTool = json.result.tools.find((t: any) => t.name === 'deletePosts')
+ expect(deleteTool).toBeDefined()
+ expect(deleteTool.inputSchema.properties.locale).toBeDefined()
+ expect(deleteTool.inputSchema.properties.fallbackLocale).toBeDefined()
+ })
+
+ it('should create post with specific locale', async () => {
+ const apiKey = await getApiKey()
+ const response = await restClient.POST('/mcp', {
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ Accept: 'application/json, text/event-stream',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ id: 1,
+ jsonrpc: '2.0',
+ method: 'tools/call',
+ params: {
+ name: 'createPosts',
+ arguments: {
+ title: 'Hello World',
+ content: 'This is my first post in English',
+ locale: 'en',
+ },
+ },
+ }),
+ })
+
+ const json = await parseStreamResponse(response)
+
+ expect(json.result).toBeDefined()
+ expect(json.result.content[0].text).toContain('Resource created successfully')
+ expect(json.result.content[0].text).toContain('"title": "Hello World"')
+ expect(json.result.content[0].text).toContain('"content": "This is my first post in English"')
+ })
+
+ it('should update post to add translation', async () => {
+ // First create a post in English
+ const englishPost = await payload.create({
+ collection: 'posts',
+ data: {
+ title: 'English Title',
+ content: 'English Content',
+ },
+ })
+
+ // Update with Spanish translation via MCP
+ const apiKey = await getApiKey(true)
+ const response = await restClient.POST('/mcp', {
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ Accept: 'application/json, text/event-stream',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ id: 1,
+ jsonrpc: '2.0',
+ method: 'tools/call',
+ params: {
+ name: 'updatePosts',
+ arguments: {
+ id: englishPost.id,
+ title: 'Título Español',
+ content: 'Contenido Español',
+ locale: 'es',
+ },
+ },
+ }),
+ })
+
+ const json = await parseStreamResponse(response)
+
+ expect(json.result).toBeDefined()
+ expect(json.result.content[0].text).toContain('Document updated successfully')
+ expect(json.result.content[0].text).toContain('"title": "Título Español"')
+ expect(json.result.content[0].text).toContain('"content": "Contenido Español"')
+ })
+
+ it('should find post in specific locale', async () => {
+ // Create a post with English and Spanish translations
+ const post = await payload.create({
+ collection: 'posts',
+ data: {
+ title: 'English Post',
+ content: 'English Content',
+ },
+ })
+
+ await payload.update({
+ id: post.id,
+ collection: 'posts',
+ data: {
+ title: 'Publicación Española',
+ content: 'Contenido Español',
+ },
+ // @ts-expect-error - locale is a valid property
+ locale: 'es',
+ })
+
+ // Find in Spanish via MCP
+ const apiKey = await getApiKey()
+ const response = await restClient.POST('/mcp', {
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ Accept: 'application/json, text/event-stream',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ id: 1,
+ jsonrpc: '2.0',
+ method: 'tools/call',
+ params: {
+ name: 'findPosts',
+ arguments: {
+ id: post.id,
+ locale: 'es',
+ },
+ },
+ }),
+ })
+
+ const json = await parseStreamResponse(response)
+
+ expect(json.result).toBeDefined()
+ expect(json.result.content[0].text).toContain(
+ '"title": "Publicación Española (MCP Hook Override)"',
+ )
+ expect(json.result.content[0].text).toContain('"content": "Contenido Español"')
+ })
+
+ it('should find post with locale "all"', async () => {
+ // Create a post with multiple translations
+ const post = await payload.create({
+ collection: 'posts',
+ data: {
+ title: 'English Title',
+ content: 'English Content',
+ },
+ })
+
+ await payload.update({
+ id: post.id,
+ collection: 'posts',
+ data: {
+ title: 'Título Español',
+ content: 'Contenido Español',
+ },
+ // @ts-expect-error - locale is a valid property
+ locale: 'es',
+ })
+
+ await payload.update({
+ id: post.id,
+ collection: 'posts',
+ data: {
+ title: 'Titre Français',
+ content: 'Contenu Français',
+ },
+ // @ts-expect-error - locale is a valid property
+ locale: 'fr',
+ })
+
+ // Find with locale: all via MCP
+ const apiKey = await getApiKey()
+ const response = await restClient.POST('/mcp', {
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ Accept: 'application/json, text/event-stream',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ id: 1,
+ jsonrpc: '2.0',
+ method: 'tools/call',
+ params: {
+ name: 'findPosts',
+ arguments: {
+ id: post.id,
+ locale: 'all',
+ },
+ },
+ }),
+ })
+
+ const json = await parseStreamResponse(response)
+
+ expect(json.result).toBeDefined()
+ const responseText = json.result.content[0].text
+
+ // Should contain locale objects with all translations
+ expect(responseText).toContain('"en":')
+ expect(responseText).toContain('"es":')
+ expect(responseText).toContain('"fr":')
+ expect(responseText).toContain('English Title (MCP Hook Override)')
+ expect(responseText).toContain('Título Español (MCP Hook Override)')
+ expect(responseText).toContain('Titre Français (MCP Hook Override)')
+ })
+
+ it('should use fallback locale when translation does not exist', async () => {
+ // Create a post only in English with explicit content
+ const post = await payload.create({
+ collection: 'posts',
+ data: {
+ title: 'English Only Title',
+ },
+ // @ts-expect-error - locale is a valid property
+ locale: 'en',
+ })
+
+ // Try to find in French (which doesn't exist)
+ const apiKey = await getApiKey()
+ const response = await restClient.POST('/mcp', {
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ Accept: 'application/json, text/event-stream',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ id: 1,
+ jsonrpc: '2.0',
+ method: 'tools/call',
+ params: {
+ name: 'findPosts',
+ arguments: {
+ id: post.id,
+ locale: 'fr',
+ },
+ },
+ }),
+ })
+
+ const json = await parseStreamResponse(response)
+
+ expect(json.result).toBeDefined()
+ // Should fallback to English (with default value for content)
+ expect(json.result.content[0].text).toContain(
+ '"title": "English Only Title (MCP Hook Override)"',
+ )
+ expect(json.result.content[0].text).toContain('"content": "Hello World."')
+ })
+ })
})