Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/migration-guide/v4.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -891,7 +891,7 @@ createAPIKeyFields({

The MCP plugin was refactored. The public config API, the built-in tool inputs, and the API key collection schema all changed. Update your plugin config and delete + recreate any existing API keys.

**Collections and globals are now opt-out.** Drop the `enabled` flag. Every collection and global is exposed by default through the generic built-in tools (`getConfigInfo`, `getCollectionSchema`, `findDocuments`, `createDocument`, `updateDocument`, `deleteDocuments`, plus `getGlobalSchema`, `findGlobal`, and `updateGlobal` for globals). Turn individual operations off via simple config keys like `tools: { create: false }`.
**Collections and globals are now opt-out.** Drop the `enabled` flag. Every collection and global is exposed by default through the generic built-in tools. For collections, this includes tools like `getCollectionSchema`, `findDocuments`, `countDocuments`, `createDocument`, `updateDocument`, `deleteDocuments`, `duplicateDocument`, and `findDistinct`, plus version tools when versions are enabled. For globals, this includes `getGlobalSchema`, `findGlobal`, and `updateGlobal`, plus version tools when versions are enabled. Turn individual operations off via simple config keys like `tools: { create: false }`.

```diff
mcpPlugin({
Expand Down
71 changes: 71 additions & 0 deletions packages/plugin-mcp/src/mcp/builtin/collections/countTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { z } from 'zod'

import { defineCollectionTool } from '../../../defineTool.js'
import { getLogger } from '../../../utils/getLogger.js'
import { localAPIDefaults } from '../../../utils/localAPIDefaults.js'
import { whereSchema } from '../../../utils/whereSchema.js'

const DEFAULT_DESCRIPTION =
'Count documents in any collection by passing the collection slug and optional where clause.'

export const countDocumentsTool = defineCollectionTool({
annotations: {
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
readOnlyHint: true,
title: 'Count Documents',
},
description: DEFAULT_DESCRIPTION,
input: z.object({
locale: z.string().describe('Optional: locale code to count documents in').optional(),
trash: z
.boolean()
.describe('Optional: include soft-deleted documents when trash is enabled on the collection')
.optional(),
where: whereSchema
.describe(
'Optional: where clause for filtering. Use field names with Payload operators, and/or arrays for grouping. Example: {"title":{"contains":"test"}}',
)
.optional(),
}),
}).handler(async ({ authorizedMCP, collectionSlug, input, req }) => {
const payload = req.payload
const logger = getLogger({ payload })
const { locale, trash, where } = input

logger.info(`Counting documents in collection: ${collectionSlug}`)

try {
const result = await payload.count({
collection: collectionSlug,
req,
...localAPIDefaults(authorizedMCP),
...(locale ? { locale } : {}),
...(trash !== undefined ? { trash } : {}),
...(where ? { where } : {}),
})

return {
content: [
{
type: 'text',
text: `Collection "${collectionSlug}" contains ${result.totalDocs} matching documents.\n\`\`\`json\n${JSON.stringify(result)}\n\`\`\``,
},
],
doc: result,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error(`Error counting documents in ${collectionSlug}: ${errorMessage}`)
return {
content: [
{
type: 'text',
text: `❌ **Error counting documents in collection "${collectionSlug}":** ${errorMessage}`,
},
],
isError: true,
}
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { z } from 'zod'

import { defineCollectionTool } from '../../../defineTool.js'
import { getLogger } from '../../../utils/getLogger.js'
import { localAPIDefaults } from '../../../utils/localAPIDefaults.js'
import { whereSchema } from '../../../utils/whereSchema.js'

const DEFAULT_DESCRIPTION =
'Count document versions in any version-enabled collection by passing the collection slug and optional where clause.'

export const countVersionsTool = defineCollectionTool({
annotations: {
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
readOnlyHint: true,
title: 'Count Versions',
},
description: DEFAULT_DESCRIPTION,
input: z.object({
locale: z
.string()
.describe('Optional: locale code to count versions in')
.optional(),
where: whereSchema
.describe(
'Optional: where clause for filtering versions. Version document fields are usually under "version". Example: {"version.title":{"contains":"test"}}',
)
.optional(),
}),
}).handler(async ({ authorizedMCP, collectionSlug, input, req }) => {
const payload = req.payload
const logger = getLogger({ payload })
const { locale, where } = input

logger.info(`Counting versions in collection: ${collectionSlug}`)

try {
const result = await payload.countVersions({
collection: collectionSlug,
req,
...localAPIDefaults(authorizedMCP),
...(locale ? { locale } : {}),
...(where ? { where } : {}),
})

return {
content: [
{
type: 'text',
text: `Collection "${collectionSlug}" contains ${result.totalDocs} matching versions.\n\`\`\`json\n${JSON.stringify(result)}\n\`\`\``,
},
],
doc: result,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error(`Error counting versions in ${collectionSlug}: ${errorMessage}`)
return {
content: [
{
type: 'text',
text: `❌ **Error counting versions in collection "${collectionSlug}":** ${errorMessage}`,
},
],
isError: true,
}
}
})
132 changes: 132 additions & 0 deletions packages/plugin-mcp/src/mcp/builtin/collections/duplicateTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type { PopulateType, SelectType } from 'payload'

import { z } from 'zod'

import { defineCollectionTool } from '../../../defineTool.js'
import { getLogger } from '../../../utils/getLogger.js'
import {
getCollectionVirtualFieldNames,
stripVirtualFields,
} from '../../../utils/getVirtualFieldNames.js'
import { localAPIDefaults } from '../../../utils/localAPIDefaults.js'
import { transformPointDataToPayload } from '../../../utils/transformPointDataToPayload.js'
import { formatCollectionError } from './formatCollectionError.js'

const DEFAULT_DESCRIPTION =
'Duplicate a document in any collection by passing the collection slug and source document ID.'

export const duplicateDocumentTool = defineCollectionTool({
annotations: {
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
readOnlyHint: false,
title: 'Duplicate Document',
},
description: DEFAULT_DESCRIPTION,
input: z.object({
id: z.union([z.string(), z.number()]).describe('The ID of the document to duplicate'),
data: z
.record(z.string(), z.unknown())
.describe('Optional: fields to override on the duplicated document')
.optional(),
depth: z
.number()
.int()
.min(0)
.max(10)
.describe('How many levels deep to populate relationships in response')
.optional()
.default(0),
draft: z
.boolean()
.describe('Whether to create the duplicate as a draft')
.optional()
.default(false),
fallbackLocale: z
.string()
.describe('Optional: fallback locale code to use when requested locale is not available')
.optional(),
locale: z
.string()
.describe(
'Optional: locale code to duplicate in (e.g., "en", "es"). Defaults to the default locale',
)
.optional(),
populate: z
.record(z.string(), z.unknown())
.describe(
'Optional: control which fields to include from populated relationship or upload documents.',
)
.optional(),
select: z
.record(z.string(), z.unknown())
.describe(
'Optional: define exactly which fields you\'d like to return in the response, e.g., {"title": true}',
)
.optional(),
selectedLocales: z
.array(z.string())
.describe('Optional: localized field locales to include in the duplicated document')
.optional(),
showHiddenFields: z
.boolean()
.describe('Optional: include hidden fields in the duplicated document response')
.optional(),
}),
}).handler(async ({ authorizedMCP, collectionSlug, input, req }) => {
const payload = req.payload
const logger = getLogger({ payload })
const {
id,
data,
depth,
draft,
fallbackLocale,
locale,
populate,
select,
selectedLocales,
showHiddenFields,
} = input

logger.info(`Duplicating document in collection: ${collectionSlug} with ID: ${id}`)

try {
const virtualFieldNames = getCollectionVirtualFieldNames(payload.config, collectionSlug)
const inputData = data ? stripVirtualFields(data, virtualFieldNames) : undefined
const parsedData = inputData ? transformPointDataToPayload(inputData) : undefined

const result = await payload.duplicate({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are we doing for access control here? Shouldn't this have overrideAccess: false?

id,
collection: collectionSlug,
depth,
draft,
req,
...localAPIDefaults(authorizedMCP),
...(parsedData ? { data: parsedData } : {}),
...(locale ? { locale } : {}),
...(fallbackLocale ? { fallbackLocale } : {}),
...(populate ? { populate: populate as PopulateType } : {}),
...(select ? { select: select as SelectType } : {}),
...(selectedLocales ? { selectedLocales } : {}),
...(showHiddenFields !== undefined ? { showHiddenFields } : {}),
})

logger.info(`Successfully duplicated document in ${collectionSlug} from ID: ${id}`)

return {
content: [
{
type: 'text',
text: `Document duplicated successfully in collection "${collectionSlug}"!\nDuplicated document:\n\`\`\`json\n${JSON.stringify(result)}\n\`\`\``,
},
],
doc: result as Record<string, unknown>,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error(`Error duplicating document in ${collectionSlug}: ${errorMessage}`)
return formatCollectionError({ action: 'duplicating', collectionSlug, error, req })
}
})
Loading
Loading