From 2d56ce211b6abf9b863630e53f2e692cc2714b74 Mon Sep 17 00:00:00 2001 From: Steven Ceuppens Date: Fri, 24 Oct 2025 12:56:05 +0200 Subject: [PATCH 1/4] feat(plugin-mcp): add localization support to resource operations - Add locale and fallbackLocale parameters to all resource tools (create, update, find, delete) - Add comprehensive integration tests for localization features - Update documentation with localization usage examples and MCP client configuration - Follow Payload REST API localization pattern for consistency --- docs/plugins/mcp.mdx | 213 +++++++++++++ .../src/mcp/tools/resource/create.ts | 32 +- .../src/mcp/tools/resource/delete.ts | 10 +- .../plugin-mcp/src/mcp/tools/resource/find.ts | 12 +- .../src/mcp/tools/resource/update.ts | 22 +- packages/plugin-mcp/src/mcp/tools/schemas.ts | 40 +++ test/plugin-mcp/collections/Posts.ts | 2 + test/plugin-mcp/config.ts | 20 ++ test/plugin-mcp/int.spec.ts | 281 +++++++++++++++++- 9 files changed, 619 insertions(+), 13 deletions(-) diff --git a/docs/plugins/mcp.mdx b/docs/plugins/mcp.mdx index f02ab0fdfd5..11a0e2c9d98 100644 --- a/docs/plugins/mcp.mdx +++ b/docs/plugins/mcp.mdx @@ -31,6 +31,8 @@ This plugin adds [Model Context Protocol](https://modelcontextprotocol.io/docs/g - You can allow / disallow `find`, `create`, `update`, and `delete` operations for each collection - You can to allow / disallow capabilities in real time - You can define your own Prompts, Tools and Resources available over MCP +- Full support for Payload's localization features with `locale` and `fallbackLocale` parameters +- HTTP-based MCP server compatible with AI tools supporting the Model Context Protocol ## Installation @@ -69,6 +71,51 @@ const config = buildConfig({ export default config ``` +## Connecting AI Tools + +After installing and configuring the plugin, you can connect AI tools that support the Model Context Protocol (MCP) to your Payload server. + +### 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 **MCP → API Keys** +4. Click **Create New** +5. Configure permissions for each collection (enable find, create, update, delete as needed) +6. Click **Create** and copy the generated API key + +### Step 2: Configure Your MCP Client + +Add your Payload MCP server to your MCP client's configuration file (typically `.mcp.json` or similar): + +```json +{ + "mcpServers": { + "payload-cms": { + "type": "http", + "url": "http://localhost:3000/api/mcp", + "headers": { + "Authorization": "Bearer YOUR_API_KEY_HERE" + } + } + } +} +``` + +Replace `YOUR_API_KEY_HERE` with the API key you created in Step 1. + +**Configuration Notes:** + +- **URL:** If you're using a custom `basePath` in your MCP plugin configuration, update the URL accordingly (e.g., `http://localhost:3000/custom-path/mcp`) +- **Production:** For production deployments, use your domain instead of `localhost` (e.g., `https://yourdomain.com/api/mcp`) +- **Headers:** The `Authorization` header with Bearer token is required for authentication + +### Step 3: Restart Your MCP Client + +Restart your MCP client for the configuration to take effect. The Payload MCP server should now be available, and the AI tool will be able to interact with your configured collections. + +Refer to your specific MCP client's documentation for additional configuration options and setup instructions. + ### Options | Option | Type | Description | @@ -142,6 +189,172 @@ mcpPlugin({ }) ``` +## Localization Support + +The MCP plugin fully supports Payload's localization features, working the same way as Payload's REST API. All resource operations (create, update, find, delete) accept `locale` and `fallbackLocale` parameters, allowing you to manage multilingual content through MCP. + +### Prerequisites + +First, configure localization in your Payload config: + +```ts +const config = buildConfig({ + localization: { + defaultLocale: 'en', + fallback: true, + locales: [ + { code: 'en', label: 'English' }, + { code: 'es', label: 'Spanish' }, + { code: 'fr', label: 'French' }, + ], + }, + collections: [ + { + slug: 'posts', + fields: [ + { + name: 'title', + type: 'text', + localized: true, // Enable localization for this field + }, + { + name: 'content', + type: 'richText', + localized: true, + }, + ], + }, + ], + plugins: [ + mcpPlugin({ + collections: { + posts: { + enabled: true, + }, + }, + }), + ], +}) +``` + +### Creating Localized Content + +Create content in a specific locale using the `locale` parameter: + +```ts +// Via MCP tool call +{ + "name": "createPosts", + "arguments": { + "title": "Hello World", + "content": "This is my first post in English", + "locale": "en" + } +} +``` + +### Adding Translations + +Add translations to existing content by updating with a different locale: + +```ts +// First, create in English +{ + "name": "createPosts", + "arguments": { + "title": "Hello World", + "content": "English content" + } +} + +// Then, add Spanish translation +{ + "name": "updatePosts", + "arguments": { + "id": "document-id", + "title": "Hola Mundo", + "content": "Contenido en español", + "locale": "es" + } +} +``` + +### Retrieving Localized Content + +Retrieve content in a specific locale: + +```ts +// Get Spanish version +{ + "name": "findPosts", + "arguments": { + "id": "document-id", + "locale": "es" + } +} +``` + +Retrieve all translations at once using `locale: 'all'`: + +```ts +{ + "name": "findPosts", + "arguments": { + "id": "document-id", + "locale": "all" + } +} + +// Response will include all translations: +// { +// "title": { +// "en": "Hello World", +// "es": "Hola Mundo", +// "fr": "Bonjour le Monde" +// }, +// "content": { ... } +// } +``` + +### Fallback Locales + +When requesting content in a locale that doesn't have a translation, Payload will automatically fall back to the default locale: + +```ts +// Request French content when only English exists +{ + "name": "findPosts", + "arguments": { + "id": "document-id", + "locale": "fr" + } +} + +// Returns English content (default locale) as fallback +``` + +You can also specify a custom fallback locale: + +```ts +{ + "name": "findPosts", + "arguments": { + "id": "document-id", + "locale": "fr", + "fallbackLocale": "es" // Use Spanish as fallback instead of default + } +} +``` + +### Locale Parameters + +All resource operation tools support these parameters: + +| Parameter | Type | Description | +| ---------------- | -------- | ----------------------------------------------------------------------------------------------- | +| `locale` | `string` | The locale code to use for the operation (e.g., 'en', 'es'). Use 'all' to retrieve all locales. | +| `fallbackLocale` | `string` | Optional fallback locale code to use when the requested locale is not available. | + ## Prompts Prompts allow LLMs to generate structured messages for specific tasks. Each prompt defines a schema for arguments and returns formatted messages: diff --git a/packages/plugin-mcp/src/mcp/tools/resource/create.ts b/packages/plugin-mcp/src/mcp/tools/resource/create.ts index edfbd562f42..0db186af1f6 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,8 @@ export const createResourceTool = ( ) => { const tool = async ( data: string, + locale?: string, + fallbackLocale?: string, ): Promise<{ content: Array<{ text: string @@ -27,7 +31,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 { @@ -53,6 +59,8 @@ export const createResourceTool = ( // TODO: Move the override to a `beforeChange` hook and extend the payloadAPI context req to include MCP request info. data: collections?.[collectionSlug]?.override?.(parsedData, req) || parsedData, user, + ...(locale && { locale }), + ...(fallbackLocale && { fallbackLocale }), }) if (verboseLogs) { @@ -108,13 +116,29 @@ ${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, + 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)}`, `${toolSchemas.createResource.description.trim()}\n\n${collections?.[collectionSlug]?.description || ''}`, - convertedFields.shape, + createResourceSchema.shape, async (params: Record) => { - const data = JSON.stringify(params) - return await tool(data) + const { fallbackLocale, locale, ...fieldData } = params + const data = JSON.stringify(fieldData) + return await tool(data, 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 264155b0082..945fb41cd67 100644 --- a/packages/plugin-mcp/src/mcp/tools/resource/delete.ts +++ b/packages/plugin-mcp/src/mcp/tools/resource/delete.ts @@ -18,6 +18,8 @@ export const deleteResourceTool = ( id?: 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}` : ''}`, ) } @@ -78,6 +80,8 @@ export const deleteResourceTool = ( collection: collectionSlug, depth, user, + ...(locale && { locale }), + ...(fallbackLocale && { fallbackLocale }), } // Delete by ID or where clause @@ -202,8 +206,8 @@ ${JSON.stringify(errors, null, 2)} `delete${collectionSlug.charAt(0).toUpperCase() + toCamelCase(collectionSlug).slice(1)}`, `${toolSchemas.deleteResource.description.trim()}\n\n${collections?.[collectionSlug]?.description || ''}`, 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 3d58130f37d..b5d732dcc9b 100644 --- a/packages/plugin-mcp/src/mcp/tools/resource/find.ts +++ b/packages/plugin-mcp/src/mcp/tools/resource/find.ts @@ -20,6 +20,8 @@ export const findResourceTool = ( 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}` : ''}`, ) } @@ -65,6 +67,8 @@ export const findResourceTool = ( id, collection: collectionSlug, user, + ...(locale && { locale }), + ...(fallbackLocale && { fallbackLocale }), }) if (verboseLogs) { @@ -116,6 +120,8 @@ ${JSON.stringify(doc, null, 2)}`, limit, page, user, + ...(locale && { locale }), + ...(fallbackLocale && { fallbackLocale }), } if (sort) { @@ -186,8 +192,8 @@ Page: ${result.page} of ${result.totalPages} `find${collectionSlug.charAt(0).toUpperCase() + toCamelCase(collectionSlug).slice(1)}`, `${toolSchemas.findResources.description.trim()}\n\n${collections?.[collectionSlug]?.description || ''}`, 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 91386e052c6..3cf7f1225b4 100644 --- a/packages/plugin-mcp/src/mcp/tools/resource/update.ts +++ b/packages/plugin-mcp/src/mcp/tools/resource/update.ts @@ -27,6 +27,8 @@ export const updateResourceTool = ( 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}` : ''}`, ) } @@ -118,6 +120,8 @@ export const updateResourceTool = ( user, ...(filePath && { filePath }), ...(overwriteExistingFiles && { overwriteExistingFiles }), + ...(locale && { locale }), + ...(fallbackLocale && { fallbackLocale }), } if (verboseLogs) { @@ -164,6 +168,8 @@ ${JSON.stringify(result, null, 2)} where: whereClause, ...(filePath && { filePath }), ...(overwriteExistingFiles && { overwriteExistingFiles }), + ...(locale && { locale }), + ...(fallbackLocale && { fallbackLocale }), } if (verboseLogs) { @@ -264,7 +270,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() @@ -290,7 +306,9 @@ ${JSON.stringify(errors, null, 2)} id, depth, draft, + fallbackLocale, filePath, + locale, overrideLock, overwriteExistingFiles, where, @@ -307,6 +325,8 @@ ${JSON.stringify(errors, null, 2)} 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 bceebe98e08..5d206592add 100644 --- a/packages/plugin-mcp/src/mcp/tools/schemas.ts +++ b/packages/plugin-mcp/src/mcp/tools/schemas.ts @@ -10,6 +10,10 @@ export const toolSchemas = { .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,6 +57,16 @@ 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', + ), }), }, @@ -64,7 +84,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() @@ -94,6 +124,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 936216dc3db..7f545f58eac 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', }, diff --git a/test/plugin-mcp/config.ts b/test/plugin-mcp/config.ts index b1921db1e70..117f1a02b58 100644 --- a/test/plugin-mcp/config.ts +++ b/test/plugin-mcp/config.ts @@ -21,6 +21,24 @@ export default buildConfigWithDefaults({ }, }, collections: [Users, Media, Posts, Products], + localization: { + defaultLocale: 'en', + fallback: true, + locales: [ + { + code: 'en', + label: 'English', + }, + { + code: 'es', + label: 'Spanish', + }, + { + code: 'fr', + label: 'French', + }, + ], + }, onInit: seed, plugins: [ mcpPlugin({ @@ -43,6 +61,8 @@ export default buildConfigWithDefaults({ enabled: { find: true, create: true, + update: true, + delete: true, }, description: 'This is a Payload collection with Post documents.', override: (original: Record, req) => { diff --git a/test/plugin-mcp/int.spec.ts b/test/plugin-mcp/int.spec.ts index eec3fdf4442..b21ef315943 100644 --- a/test/plugin-mcp/int.spec.ts +++ b/test/plugin-mcp/int.spec.ts @@ -49,13 +49,13 @@ 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 }, + posts: { find: true, create: true, update: enableUpdate, delete: enableDelete }, apiKey: randomUUID(), user: userId, }, @@ -382,4 +382,281 @@ describe('@payloadcms/plugin-mcp', () => { expect(json.result.content[1].type).toBe('text') expect(json.result.content[1].text).toContain('Override MCP response for Posts!') }) + + 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": "Title Override: 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": "Title Override: 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', + }, + 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"') + 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', + }, + locale: 'es', + }) + + await payload.update({ + id: post.id, + collection: 'posts', + data: { + title: 'Titre Français', + content: 'Contenu Français', + }, + 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') + expect(responseText).toContain('Título Español') + expect(responseText).toContain('Titre Français') + }) + + 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', + }, + 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"') + expect(json.result.content[0].text).toContain('"content": "Hello World."') + }) + }) }) From 4addaa1af8b37bb37ea081268f04e14e8616b093 Mon Sep 17 00:00:00 2001 From: Steven Ceuppens Date: Tue, 28 Oct 2025 09:41:02 +0100 Subject: [PATCH 2/4] docs(plugin-mcp): clarify connection methods and current HTTP support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated documentation to accurately reflect that HTTP is the currently supported connection method, with REDIS and STDIO under consideration for future releases. Added a dedicated "Connection Methods" section with HTTP configuration example to provide clearer guidance for users. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/plugins/mcp.mdx | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/plugins/mcp.mdx b/docs/plugins/mcp.mdx index 11a0e2c9d98..cc7796b6b6b 100644 --- a/docs/plugins/mcp.mdx +++ b/docs/plugins/mcp.mdx @@ -32,7 +32,7 @@ This plugin adds [Model Context Protocol](https://modelcontextprotocol.io/docs/g - You can to allow / disallow capabilities in real time - You can define your own Prompts, Tools and Resources available over MCP - Full support for Payload's localization features with `locale` and `fallbackLocale` parameters -- HTTP-based MCP server compatible with AI tools supporting the Model Context Protocol +- MCP server supporting HTTP (with REDIS and STDIO under consideration) ## Installation @@ -116,6 +116,30 @@ Restart your MCP client for the configuration to take effect. The Payload MCP se Refer to your specific MCP client's documentation for additional configuration options and setup instructions. +## Connection Methods + +The MCP plugin supports different connection methods for communicating with AI tools: + +### HTTP (Currently Supported) + +The HTTP transport is the primary and currently supported connection method. As shown in the configuration example above, clients connect via HTTP requests to your Payload server's MCP endpoint (default: `/api/mcp`). + +**Configuration:** + +```json +{ + "mcpServers": { + "payload-cms": { + "type": "http", + "url": "http://localhost:3000/api/mcp", + "headers": { + "Authorization": "Bearer YOUR_API_KEY_HERE" + } + } + } +} +``` + ### Options | Option | Type | Description | From 7c6c9469bc4cee2bf211c29cbccdd5ad5b7ff99f Mon Sep 17 00:00:00 2001 From: Steven Ceuppens Date: Tue, 4 Nov 2025 14:29:36 +0100 Subject: [PATCH 3/4] fix(plugin-mcp): support numeric IDs for PostgreSQL compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP plugin's resource tools (find, update, delete) were only accepting string IDs, which caused validation errors when using PostgreSQL. PostgreSQL uses numeric IDs by default, while MongoDB uses string-based ObjectIDs. Changes: - Updated ID parameter schemas to accept both string and number types - Added ID-to-string conversion in find, update, and delete tools - Ensured backward compatibility with MongoDB's string IDs This fixes test failures in PR #14334 where all 4 localization tests were failing with "Expected string, received number" validation errors in the PostgreSQL integration test suite. All 15 tests now pass in both PostgreSQL and MongoDB. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/mcp/tools/resource/delete.ts | 15 +++++++------ .../plugin-mcp/src/mcp/tools/resource/find.ts | 17 ++++++++------- .../src/mcp/tools/resource/update.ts | 21 +++++++++++-------- packages/plugin-mcp/src/mcp/tools/schemas.ts | 12 ++++++++--- 4 files changed, 40 insertions(+), 25 deletions(-) diff --git a/packages/plugin-mcp/src/mcp/tools/resource/delete.ts b/packages/plugin-mcp/src/mcp/tools/resource/delete.ts index 945fb41cd67..7d278244eac 100644 --- a/packages/plugin-mcp/src/mcp/tools/resource/delete.ts +++ b/packages/plugin-mcp/src/mcp/tools/resource/delete.ts @@ -15,7 +15,7 @@ export const deleteResourceTool = ( collections: PluginMCPServerConfig['collections'], ) => { const tool = async ( - id?: string, + id?: number | string, where?: string, depth: number = 0, locale?: string, @@ -28,15 +28,18 @@ export const deleteResourceTool = ( }> => { const payload = req.payload + // Convert ID to string if it's a number (for PostgreSQL compatibility) + const idString = id !== undefined ? String(id) : undefined + if (verboseLogs) { payload.logger.info( - `[payload-mcp] Deleting resource from collection: ${collectionSlug}${id ? ` with ID: ${id}` : ' with where clause'}${locale ? `, locale: ${locale}` : ''}`, + `[payload-mcp] Deleting resource from collection: ${collectionSlug}${idString ? ` with ID: ${idString}` : ' with where clause'}${locale ? `, locale: ${locale}` : ''}`, ) } try { // Validate that either id or where is provided - if (!id && !where) { + if (!idString && !where) { payload.logger.error('[payload-mcp] Either id or where clause must be provided') const response = { content: [ @@ -85,10 +88,10 @@ export const deleteResourceTool = ( } // Delete by ID or where clause - if (id) { - deleteOptions.id = id + if (idString) { + deleteOptions.id = idString if (verboseLogs) { - payload.logger.info(`[payload-mcp] Deleting single document with ID: ${id}`) + payload.logger.info(`[payload-mcp] Deleting single document with ID: ${idString}`) } } else { deleteOptions.where = whereClause diff --git a/packages/plugin-mcp/src/mcp/tools/resource/find.ts b/packages/plugin-mcp/src/mcp/tools/resource/find.ts index b5d732dcc9b..56131291bba 100644 --- a/packages/plugin-mcp/src/mcp/tools/resource/find.ts +++ b/packages/plugin-mcp/src/mcp/tools/resource/find.ts @@ -15,7 +15,7 @@ export const findResourceTool = ( collections: PluginMCPServerConfig['collections'], ) => { const tool = async ( - id?: string, + id?: number | string, limit: number = 10, page: number = 1, sort?: string, @@ -30,9 +30,12 @@ export const findResourceTool = ( }> => { const payload = req.payload + // Convert ID to string if it's a number (for PostgreSQL compatibility) + const idString = id !== undefined ? String(id) : undefined + if (verboseLogs) { payload.logger.info( - `[payload-mcp] Reading resource from collection: ${collectionSlug}${id ? ` with ID: ${id}` : ''}, limit: ${limit}, page: ${page}${locale ? `, locale: ${locale}` : ''}`, + `[payload-mcp] Reading resource from collection: ${collectionSlug}${idString ? ` with ID: ${idString}` : ''}, limit: ${limit}, page: ${page}${locale ? `, locale: ${locale}` : ''}`, ) } @@ -61,10 +64,10 @@ export const findResourceTool = ( } // If ID is provided, use findByID - if (id) { + if (idString) { try { const doc = await payload.findByID({ - id, + id: idString, collection: collectionSlug, user, ...(locale && { locale }), @@ -72,7 +75,7 @@ export const findResourceTool = ( }) if (verboseLogs) { - payload.logger.info(`[payload-mcp] Found document with ID: ${id}`) + payload.logger.info(`[payload-mcp] Found document with ID: ${idString}`) } const response = { @@ -94,13 +97,13 @@ ${JSON.stringify(doc, null, 2)}`, } } catch (_findError) { payload.logger.warn( - `[payload-mcp] Document not found with ID: ${id} in collection: ${collectionSlug}`, + `[payload-mcp] Document not found with ID: ${idString} in collection: ${collectionSlug}`, ) const response = { content: [ { type: 'text' as const, - text: `Error: Document with ID "${id}" not found in collection "${collectionSlug}"`, + text: `Error: Document with ID "${idString}" not found in collection "${collectionSlug}"`, }, ], } diff --git a/packages/plugin-mcp/src/mcp/tools/resource/update.ts b/packages/plugin-mcp/src/mcp/tools/resource/update.ts index 3cf7f1225b4..3697638fdd9 100644 --- a/packages/plugin-mcp/src/mcp/tools/resource/update.ts +++ b/packages/plugin-mcp/src/mcp/tools/resource/update.ts @@ -20,7 +20,7 @@ export const updateResourceTool = ( ) => { const tool = async ( data: string, - id?: string, + id?: number | string, where?: string, draft: boolean = false, depth: number = 0, @@ -37,9 +37,12 @@ export const updateResourceTool = ( }> => { const payload = req.payload + // Convert ID to string if it's a number (for PostgreSQL compatibility) + const idString = id !== undefined ? String(id) : undefined + if (verboseLogs) { payload.logger.info( - `[payload-mcp] Updating resource in collection: ${collectionSlug}${id ? ` with ID: ${id}` : ' with where clause'}, draft: ${draft}${locale ? `, locale: ${locale}` : ''}`, + `[payload-mcp] Updating resource in collection: ${collectionSlug}${idString ? ` with ID: ${idString}` : ' with where clause'}, draft: ${draft}${locale ? `, locale: ${locale}` : ''}`, ) } @@ -68,7 +71,7 @@ export const updateResourceTool = ( } // Validate that either id or where is provided - if (!id && !where) { + if (!idString && !where) { payload.logger.error('[payload-mcp] Either id or where clause must be provided') const response = { content: [ @@ -108,10 +111,10 @@ export const updateResourceTool = ( } // Update by ID or where clause - if (id) { + if (idString) { // Single document update const updateOptions = { - id, + id: idString, collection: collectionSlug, data: parsedData, depth, @@ -125,7 +128,7 @@ export const updateResourceTool = ( } if (verboseLogs) { - payload.logger.info(`[payload-mcp] Updating single document with ID: ${id}`) + payload.logger.info(`[payload-mcp] Updating single document with ID: ${idString}`) } const result = await payload.update({ ...updateOptions, @@ -133,7 +136,7 @@ export const updateResourceTool = ( } as any) if (verboseLogs) { - payload.logger.info(`[payload-mcp] Successfully updated document with ID: ${id}`) + payload.logger.info(`[payload-mcp] Successfully updated document with ID: ${idString}`) } const response = { @@ -259,7 +262,7 @@ ${JSON.stringify(errors, null, 2)} // Create a new schema that combines the converted fields with update-specific parameters const updateResourceSchema = z.object({ ...convertedFields.shape, - id: z.string().optional().describe('The ID of the document to update'), + id: z.union([z.string(), z.number()]).optional().describe('The ID of the document to update'), depth: z .number() .optional() @@ -318,7 +321,7 @@ ${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, diff --git a/packages/plugin-mcp/src/mcp/tools/schemas.ts b/packages/plugin-mcp/src/mcp/tools/schemas.ts index 5d206592add..5453ace53df 100644 --- a/packages/plugin-mcp/src/mcp/tools/schemas.ts +++ b/packages/plugin-mcp/src/mcp/tools/schemas.ts @@ -5,7 +5,7 @@ export const toolSchemas = { description: 'Find documents in a Payload collection 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', @@ -73,7 +73,10 @@ export const toolSchemas = { updateResource: { description: 'Update documents in a Payload 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() @@ -115,7 +118,10 @@ export const toolSchemas = { deleteResource: { description: 'Delete documents in a Payload 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() From ee08dd4845941b8639242dae8fe4eecc4020dd46 Mon Sep 17 00:00:00 2001 From: Steven Ceuppens Date: Tue, 4 Nov 2025 14:33:53 +0100 Subject: [PATCH 4/4] fix(plugin-mcp): enable partial updates by making all fields optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the MCP update tool required all collection fields to be provided when updating a document, even for partial updates. This was because the schema converter preserved the 'required' status of fields from the collection schema. Changes: - Use Zod's `.partial()` method on converted fields to make all fields optional - This allows updating a single field without providing all required fields - Maintains backward compatibility with full updates Fixes issue where users couldn't update a single field (like a title) without passing in all the document data. This was particularly problematic with complex nested fields like layout blocks. Example: Before: updatePosts({ id: 1, title: "New Title", content: "...", ... }) ❌ After: updatePosts({ id: 1, title: "New Title" }) ✅ All 15 tests pass in both PostgreSQL and MongoDB. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/plugin-mcp/src/mcp/tools/resource/update.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/plugin-mcp/src/mcp/tools/resource/update.ts b/packages/plugin-mcp/src/mcp/tools/resource/update.ts index 3697638fdd9..0382a186396 100644 --- a/packages/plugin-mcp/src/mcp/tools/resource/update.ts +++ b/packages/plugin-mcp/src/mcp/tools/resource/update.ts @@ -260,8 +260,9 @@ ${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, + ...convertedFields.partial().shape, id: z.union([z.string(), z.number()]).optional().describe('The ID of the document to update'), depth: z .number()