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."') + }) + }) })