Compare commits

...

24 Commits

Author SHA1 Message Date
Kendell Joseph
9c4f97364e fix: ts errors 2025-09-05 13:45:04 -04:00
Kendell Joseph
46c0d3b827 feat: adds fine grained tool enabling capabilities 2025-09-05 13:38:22 -04:00
Kendell Joseph
ee16d3ec33 chore: adds example collection to test kebab case slugs 2025-09-05 13:34:45 -04:00
Kendell Joseph
fe604c5f76 feat: register and admin CRUD tool capabilities per collection 2025-09-05 11:47:58 -04:00
Kendell Joseph
23c3c06a89 feat: resource tools accept updated collections format 2025-09-04 23:05:04 -04:00
Kendell Joseph
dfe73f40c2 chore: updates text copy 2025-09-04 23:03:55 -04:00
Kendell Joseph
65ab592f76 feat: updates expected collections config properties 2025-09-04 23:03:00 -04:00
Kendell Joseph
c7efe5b6c0 chore: updates description 2025-09-04 22:26:47 -04:00
Kendell Joseph
4b3b55fe2b chore: simplifies response content 2025-09-04 21:13:40 -04:00
Kendell Joseph
0f6ce52856 fix: admin panel users can change allowed tools 2025-09-04 20:41:48 -04:00
Kendell Joseph
3c70e62751 chore: updates README 2025-09-04 15:35:39 -04:00
Kendell Joseph
5d61b2dd40 chore: updates package-lock 2025-09-04 15:32:22 -04:00
Kendell Joseph
ad02f40dc7 chore: updates test config 2025-09-04 15:31:41 -04:00
Kendell Joseph
90a8097a48 chore: removes unused schemas 2025-09-04 15:31:08 -04:00
Kendell Joseph
de16fc09a4 chore: conditionally adds experimental tools 2025-09-04 15:30:46 -04:00
Kendell Joseph
bfde7d7fa6 chore: updates groups, conditionally adds experimental groups 2025-09-04 15:29:57 -04:00
Kendell Joseph
b7d2bd394c chore: removes endpoint global, updates capitalization 2025-09-04 15:29:16 -04:00
Kendell Joseph
7ffdc10351 chore: updates types 2025-09-04 15:28:33 -04:00
Kendell Joseph
2fa78bc9ec chore: updates capitalization, removes admin endpoint config 2025-09-04 15:27:36 -04:00
Kendell Joseph
4b6bc9f9a2 chore: updates README 2025-09-04 15:26:42 -04:00
Kendell Joseph
92c01d948e feat: init commit for MCP plugin 2025-08-28 15:33:40 -04:00
Kendell Joseph
5bd01ad87e chore: initial commit 2025-08-28 15:28:55 -04:00
Kendell Joseph
f53114e0ea chore: initial commit 2025-08-28 15:27:19 -04:00
Elliot DeNolf
c1b4960795 chore(release): v3.54.0 [skip ci] 2025-08-28 09:47:54 -04:00
90 changed files with 7361 additions and 40 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.53.0",
"version": "3.54.0",
"private": true,
"type": "module",
"workspaces": [

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/admin-bar",
"version": "3.53.0",
"version": "3.54.0",
"description": "An admin bar for React apps using Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
"version": "3.53.0",
"version": "3.54.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-mongodb",
"version": "3.53.0",
"version": "3.54.0",
"description": "The officially supported MongoDB database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-postgres",
"version": "3.53.0",
"version": "3.54.0",
"description": "The officially supported Postgres database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-sqlite",
"version": "3.53.0",
"version": "3.54.0",
"description": "The officially supported SQLite database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-vercel-postgres",
"version": "3.53.0",
"version": "3.54.0",
"description": "Vercel Postgres adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/drizzle",
"version": "3.53.0",
"version": "3.54.0",
"description": "A library of shared functions used by different payload database adapters",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-nodemailer",
"version": "3.53.0",
"version": "3.54.0",
"description": "Payload Nodemailer Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-resend",
"version": "3.53.0",
"version": "3.54.0",
"description": "Payload Resend Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/graphql",
"version": "3.53.0",
"version": "3.54.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-react",
"version": "3.53.0",
"version": "3.54.0",
"description": "The official React SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-vue",
"version": "3.53.0",
"version": "3.54.0",
"description": "The official Vue SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview",
"version": "3.53.0",
"version": "3.54.0",
"description": "The official live preview JavaScript SDK for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
"version": "3.53.0",
"version": "3.54.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/payload-cloud",
"version": "3.53.0",
"version": "3.54.0",
"description": "The official Payload Cloud plugin",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "3.53.0",
"version": "3.54.0",
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
"keywords": [
"admin panel",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-cloud-storage",
"version": "3.53.0",
"version": "3.54.0",
"description": "The official cloud storage plugin for Payload CMS",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-form-builder",
"version": "3.53.0",
"version": "3.54.0",
"description": "Form builder plugin for Payload CMS",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-import-export",
"version": "3.53.0",
"version": "3.54.0",
"description": "Import-Export plugin for Payload",
"keywords": [
"payload",

52
packages/plugin-mcp/.gitignore vendored Normal file
View File

@@ -0,0 +1,52 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# plugin tarballs
/payloadcms-plugin-mcp-server-*.tgz
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
/.idea/*
!/.idea/runConfigurations
# testing
/coverage
# next.js
.next/
/out/
# production
/build
/dist
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
.env
/dev/media
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@@ -0,0 +1,12 @@
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp
**/docs/**
tsconfig.json

View File

@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"tsx": true,
"dts": true
},
"transform": {
"react": {
"runtime": "automatic",
"pragmaFrag": "React.Fragment",
"throwIfNamespace": true,
"development": false,
"useBuiltins": true
}
}
},
"module": {
"type": "es6"
}
}

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2018-2024 Payload CMS, Inc. <info@payloadcms.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,112 @@
# Payload MCP Plugin
A Payload plugin that provides MCP (Model Context Protocol) functionality.
```bash
pnpm add @payloadcms/plugin-mcp
```
## 🚀 Basic Setup
```typescript
import { buildConfig } from 'payload'
import { pluginMCP } from '@payloadcms/plugin-mcp'
export default buildConfig({
// ... your existing config
plugins: [
pluginMCP({
collections: {
posts: true,
users: true,
},
}),
],
})
```
## Tools
These tools allow LLMs to interact with collection documents by treating collection documents as MCP resources.
### Resources
| Tool | Description |
| ---------------------------------------------------- | -------------------------------------- |
| [`findResources`](src/mcp/tools/resource/find.ts) | Find documents in a collection |
| [`createResource`](src/mcp/tools/resource/create.ts) | Create a new document |
| [`updateResource`](src/mcp/tools/resource/update.ts) | Update documents by ID or where clause |
| [`deleteResource`](src/mcp/tools/resource/delete.ts) | Delete documents by ID or where clause |
## Advanced Setup
```typescript
import { z } from 'zod'
import { pluginMCP } from '@payloadcms/plugin-mcp'
export default buildConfig({
// ... your existing config
plugins: [
pluginMCP({
collections: {
posts: true,
},
// Set your own MCP server options
mcp: {
handlerOptions: {
verboseLogs: true,
maxDuration: 60,
},
serverOptions: {
serverInfo: {
name: 'My Custom MCP Server',
version: '1.0.0',
},
},
// Add your own custom tools
tools: [
{
name: 'diceRoll',
description: 'Rolls a virtual dice with a specified number of sides',
handler: (args: Record<string, unknown>) => {
const sides = (args.sides as number) || 6
const result = Math.floor(Math.random() * sides) + 1
return Promise.resolve({
content: [
{
type: 'text' as const,
text: `# Dice Roll Result\n\n**Sides:** ${sides}\n**Result:** ${result}\n\n🎲 You rolled a **${result}** on a ${sides}-sided die!`,
},
],
})
},
parameters: z.object({
sides: z
.number()
.int()
.min(2)
.max(1000)
.optional()
.default(6)
.describe('Number of sides on the dice (default: 6)'),
}).shape,
},
],
},
}),
],
})
```
## Testing your MCP Server
Use [MCP Inspector](https://github.com/modelcontextprotocol/inspector) to test your MCP server:
```bash
npx @modelcontextprotocol/inspector
```
1. Go to `http://localhost:6274/`
2. Set Transport Type to `Streamable HTTP`
3. Set URL to `http://localhost:3000/api/mcp`
4. Press _Connect_

View File

@@ -0,0 +1,73 @@
{
"name": "@payloadcms/plugin-mcp",
"version": "3.54.0",
"description": "MCP (Model Context Protocol) capabilities with Payload",
"keywords": [
"plugin",
"mcp",
"model context protocol"
],
"repository": {
"type": "git",
"url": "https://github.com/payloadcms/payload.git",
"directory": "packages/plugin-mcp"
},
"license": "MIT",
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
"maintainers": [
{
"name": "Payload",
"email": "info@payloadcms.com",
"url": "https://payloadcms.com"
}
],
"sideEffects": false,
"type": "module",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"src"
],
"scripts": {
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"clean": "rimraf {dist,*.tsbuildinfo}",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"pack:plugin": "pnpm prepublishOnly && pnpm copyfiles && pnpm pack",
"prepublishOnly": "pnpm clean && pnpm turbo build"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.2",
"@vercel/mcp-adapter": "^1.0.0",
"zod": "^3.25.50"
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"payload": "workspace:*"
},
"peerDependencies": {
"payload": "workspace:*"
},
"publishConfig": {
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"homepage:": "https://payloadcms.com"
}

View File

@@ -0,0 +1,21 @@
import type { PayloadHandler } from 'payload'
import type { PluginMCPServerConfig, ToolSettings } from '../types.js'
import { createRequestFromPayloadRequest } from '../mcp/createRequest.js'
import { getMCPHandler } from '../mcp/getMcpHandler.js'
export const initializeMCPHandler = (pluginOptions: PluginMCPServerConfig) => {
const mcpHandler: PayloadHandler = async (req) => {
// Admins can change the tool list using the admin panel, so retrieve latest global
const toolsSettings = (await req.payload.findGlobal({
slug: 'payload-mcp-tools',
user: req.user,
})) as ToolSettings
const request = createRequestFromPayloadRequest(req)
const handler = getMCPHandler(pluginOptions, toolsSettings, req)
return await handler(request)
}
return mcpHandler
}

View File

@@ -0,0 +1,352 @@
import type { GlobalConfig } from 'payload'
import type { PluginMCPServerConfig } from '../types.js'
import { toCamelCase } from '../utils/camelCase.js'
const addEnabledCollectionTools = (collections: PluginMCPServerConfig['collections']) => {
const enabledCollectionSlugs = Object.keys(collections || {}).filter((collection) => {
const fullyEnabled =
typeof collections?.[collection]?.enabled === 'boolean' && collections?.[collection]?.enabled
if (fullyEnabled) {
return true
}
const partiallyEnabled =
typeof collections?.[collection]?.enabled !== 'boolean' &&
((typeof collections?.[collection]?.enabled?.find === 'boolean' &&
collections?.[collection]?.enabled?.find === true) ||
(typeof collections?.[collection]?.enabled?.create === 'boolean' &&
collections?.[collection]?.enabled?.create === true) ||
(typeof collections?.[collection]?.enabled?.update === 'boolean' &&
collections?.[collection]?.enabled?.update === true) ||
(typeof collections?.[collection]?.enabled?.delete === 'boolean' &&
collections?.[collection]?.enabled?.delete === true))
if (partiallyEnabled) {
return true
}
})
return enabledCollectionSlugs.map((enabledCollectionSlug) => ({
type: 'collapsible' as const,
fields: [
{
name: `${enabledCollectionSlug}-capabilities`,
type: 'group' as const,
fields: [
...(collections?.[enabledCollectionSlug]?.enabled === true ||
(typeof collections?.[enabledCollectionSlug]?.enabled !== 'boolean' &&
typeof collections?.[enabledCollectionSlug]?.enabled?.find === 'boolean' &&
collections?.[enabledCollectionSlug]?.enabled?.find === true)
? [
{
name: `${enabledCollectionSlug}-find`,
type: 'checkbox' as const,
admin: {
description: `Allow clients to find ${enabledCollectionSlug}.`,
},
defaultValue: false,
label: 'Find',
},
]
: []),
...(collections?.[enabledCollectionSlug]?.enabled === true ||
(typeof collections?.[enabledCollectionSlug]?.enabled !== 'boolean' &&
typeof collections?.[enabledCollectionSlug]?.enabled?.create === 'boolean' &&
collections?.[enabledCollectionSlug]?.enabled?.create === true)
? [
{
name: `${enabledCollectionSlug}-create`,
type: 'checkbox' as const,
admin: {
description: `Allow clients to create ${enabledCollectionSlug}.`,
},
defaultValue: false,
label: 'Create',
},
]
: []),
...(collections?.[enabledCollectionSlug]?.enabled === true ||
(typeof collections?.[enabledCollectionSlug]?.enabled !== 'boolean' &&
typeof collections?.[enabledCollectionSlug]?.enabled?.update === 'boolean' &&
collections?.[enabledCollectionSlug]?.enabled?.update === true)
? [
{
name: `${enabledCollectionSlug}-update`,
type: 'checkbox' as const,
admin: {
description: `Allow clients to update ${enabledCollectionSlug}.`,
},
defaultValue: false,
label: 'Update',
},
]
: []),
...(collections?.[enabledCollectionSlug]?.enabled === true ||
(typeof collections?.[enabledCollectionSlug]?.enabled !== 'boolean' &&
typeof collections?.[enabledCollectionSlug]?.enabled?.delete === 'boolean' &&
collections?.[enabledCollectionSlug]?.enabled?.delete === true)
? [
{
name: `${enabledCollectionSlug}-delete`,
type: 'checkbox' as const,
admin: {
description: `Allow clients to delete ${enabledCollectionSlug}.`,
},
defaultValue: false,
label: 'Delete',
},
]
: []),
],
},
],
label: `${enabledCollectionSlug.charAt(0).toUpperCase() + toCamelCase(enabledCollectionSlug).slice(1)}`,
}))
}
export const createMCPToolsGlobal = (
collections: PluginMCPServerConfig['collections'],
customTools: Array<{ description: string; name: string }> = [],
experimentalTools: NonNullable<PluginMCPServerConfig['_experimental']>['tools'] = {},
): GlobalConfig => {
const customToolsFields = customTools.map((tool) => {
const camelCasedName = toCamelCase(tool.name)
return {
name: camelCasedName,
type: 'checkbox' as const,
admin: {
description: tool.description,
},
defaultValue: true,
label: camelCasedName,
}
})
return {
slug: 'payload-mcp-tools',
admin: {
group: 'MCP',
},
fields: [
...addEnabledCollectionTools(collections),
// Experimental Tools
...(process.env.NODE_ENV === 'development' &&
(experimentalTools?.collections?.enabled ||
experimentalTools?.jobs?.enabled ||
experimentalTools?.config?.enabled ||
experimentalTools?.auth?.enabled)
? [
{
type: 'collapsible' as const,
fields: [
...(experimentalTools?.collections?.enabled
? [
{
name: 'collections',
type: 'group' as const,
fields: [
{
name: 'find',
type: 'checkbox' as const,
admin: {
description:
'Allow LLMs to find and list Payload collections with optional content and document counts.',
},
defaultValue: false,
},
{
name: 'create',
type: 'checkbox' as const,
admin: {
description:
'Allow LLMs to create new Payload collections with specified fields and configuration.',
},
defaultValue: false,
},
{
name: 'update',
type: 'checkbox' as const,
admin: {
description:
'Allow LLMs to update existing Payload collections with new fields, modifications, or configuration changes.',
},
defaultValue: false,
},
{
name: 'delete',
type: 'checkbox' as const,
admin: {
description:
'Allow LLMs to delete Payload collections and optionally update the configuration.',
},
defaultValue: false,
},
],
},
]
: []),
...(experimentalTools?.jobs?.enabled
? [
{
name: 'jobs',
type: 'group' as const,
fields: [
{
name: 'create',
type: 'checkbox' as const,
admin: {
description:
'Allow LLMs to create new Payload jobs (tasks and workflows) with custom schemas and configuration.',
},
defaultValue: false,
},
{
name: 'run',
type: 'checkbox' as const,
admin: {
description:
'Allow LLMs to execute Payload jobs with custom input data and queue options.',
},
defaultValue: false,
},
{
name: 'update',
type: 'checkbox' as const,
admin: {
description:
'Allow LLMs to update existing Payload jobs with new schemas, configuration, or handler code.',
},
defaultValue: false,
},
],
},
]
: []),
...(experimentalTools?.config?.enabled
? [
{
name: 'config',
type: 'group' as const,
fields: [
{
name: 'find',
type: 'checkbox' as const,
admin: {
description:
'Allow LLMs to read and display a Payload configuration file.',
},
defaultValue: false,
},
{
name: 'update',
type: 'checkbox' as const,
admin: {
description:
'Allow LLMs to update a Payload configuration file with various modifications.',
},
defaultValue: false,
},
],
},
]
: []),
...(experimentalTools?.auth?.enabled
? [
{
name: 'auth',
type: 'group' as const,
fields: [
{
name: 'auth',
type: 'checkbox' as const,
admin: {
description:
'Allow LLMs to check authentication status for a user by setting custom headers. (e.g. {"Authorization": "Bearer <token>"})',
},
defaultValue: false,
label: 'Check Auth Status',
},
{
name: 'login',
type: 'checkbox' as const,
admin: {
description:
'Allow LLMs to authenticate a user with email and password.',
},
defaultValue: false,
label: 'User Login',
},
{
name: 'verify',
type: 'checkbox' as const,
admin: {
description:
'Allow LLMs to verify a user email with a verification token.',
},
defaultValue: false,
label: 'Email Verification',
},
{
name: 'resetPassword',
type: 'checkbox' as const,
admin: {
description:
'Allow LLMs to reset a user password with a reset token.',
},
defaultValue: false,
label: 'Reset Password',
},
{
name: 'forgotPassword',
type: 'checkbox' as const,
admin: {
description: 'Allow LLMs to send a password reset email to a user.',
},
defaultValue: false,
label: 'Forgot Password',
},
{
name: 'unlock',
type: 'checkbox' as const,
admin: {
description:
'Allow LLMs to unlock a user account that has been locked due to failed login attempts.',
},
defaultValue: false,
label: 'Unlock Account',
},
],
},
]
: []),
],
label: 'Experimental Tools',
},
]
: []),
...(customTools.length > 0
? [
{
type: 'collapsible' as const,
fields: [
{
name: 'custom',
type: 'group' as const,
fields: customToolsFields,
},
],
label: 'Custom Tools',
},
]
: []),
],
label: 'Tools',
}
}

View File

@@ -0,0 +1,59 @@
import type { Config } from 'payload'
import type { PluginMCPServerConfig } from './types.js'
import { initializeMCPHandler } from './endpoints/mcp.js'
import { createMCPToolsGlobal } from './globals/createMCPToolsGlobal.js'
export const pluginMCP =
(pluginOptions: PluginMCPServerConfig) =>
(config: Config): Config => {
if (!config.globals) {
config.globals = []
}
// Collections
const collections = pluginOptions.collections || {}
// Extract custom tools for the global config
const customTools =
pluginOptions.mcp?.tools?.map((tool) => ({
name: tool.name,
description: tool.description,
})) || []
const experimentalTools = pluginOptions?._experimental?.tools || {}
// Add MCP globals.
config.globals.push(createMCPToolsGlobal(collections, customTools, experimentalTools))
/**
* If the plugin is disabled, we still want to keep added collections/fields so the database schema is consistent which is important for migrations.
* If your plugin heavily modifies the database schema, you may want to remove this property.
*/
if (pluginOptions.disabled) {
return config
}
if (!config.endpoints) {
config.endpoints = []
}
// This is the primary MCP Server Endpoint.
// Payload will automatically add the /api prefix to the path, so the full path is `/api/mcp`
// NOTE: This is only transport method until we add full support for SSE which will be another endpoint at `/api/sse`
config.endpoints.push({
handler: initializeMCPHandler(pluginOptions),
method: 'post',
path: '/mcp',
})
// The GET response is always: {"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed."},"id":null}
// This is expected behavior and MCP clients should always use the POST endpoint.
config.endpoints.push({
handler: initializeMCPHandler(pluginOptions),
method: 'get',
path: '/mcp',
})
return config
}

View File

@@ -0,0 +1,13 @@
import { APIError, type PayloadRequest } from 'payload'
export const createRequestFromPayloadRequest = (req: PayloadRequest) => {
if (!req.url) {
throw new APIError('Request URL is required', 500)
}
return new Request(req.url, {
body: req.body,
duplex: 'half',
headers: req.headers,
method: req.method,
} as { duplex: 'half' } & RequestInit)
}

View File

@@ -0,0 +1,379 @@
import type { PayloadRequest } from 'payload'
import { createMcpHandler } from '@vercel/mcp-adapter'
import { join } from 'path'
import type { PluginMCPServerConfig, ToolSettings } from '../types.js'
import { toCamelCase } from '../utils/camelCase.js'
import { registerTool } from './registerTool.js'
// Tools
import { createResourceTool } from './tools/resource/create.js'
import { deleteResourceTool } from './tools/resource/delete.js'
import { findResourceTool } from './tools/resource/find.js'
import { updateResourceTool } from './tools/resource/update.js'
// Experimental Tools
import { authTool } from './tools/auth/auth.js'
import { forgotPasswordTool } from './tools/auth/forgotPassword.js'
import { loginTool } from './tools/auth/login.js'
import { resetPasswordTool } from './tools/auth/resetPassword.js'
import { unlockTool } from './tools/auth/unlock.js'
import { verifyTool } from './tools/auth/verify.js'
import { createCollectionTool } from './tools/collection/create.js'
import { deleteCollectionTool } from './tools/collection/delete.js'
import { findCollectionTool } from './tools/collection/find.js'
import { updateCollectionTool } from './tools/collection/update.js'
import { findConfigTool } from './tools/config/find.js'
import { updateConfigTool } from './tools/config/update.js'
import { createJobTool } from './tools/job/create.js'
import { runJobTool } from './tools/job/run.js'
import { updateJobTool } from './tools/job/update.js'
export const getMCPHandler = (
pluginOptions: PluginMCPServerConfig,
toolSettings: ToolSettings,
req: PayloadRequest,
) => {
const payload = req.payload
// MCP Server and Handler Options
const MCPOptions = pluginOptions.mcp || {}
const customMCPTools = MCPOptions.tools || []
const MCPHandlerOptions = MCPOptions.handlerOptions || {}
const serverOptions = MCPOptions.serverOptions || {}
const useVerboseLogs = MCPHandlerOptions.verboseLogs ?? false
// Experimental MCP Tool Requirements
const isDevelopment = process.env.NODE_ENV === 'development'
const experimentalTools: NonNullable<PluginMCPServerConfig['_experimental']>['tools'] =
pluginOptions?._experimental?.tools || {}
const collectionsPluginConfig = pluginOptions.collections || {}
const collectionsDirPath =
experimentalTools && experimentalTools.collections?.collectionsDirPath
? experimentalTools.collections.collectionsDirPath
: join(process.cwd(), 'src/collections')
const configFilePath =
experimentalTools && experimentalTools.config?.configFilePath
? experimentalTools.config.configFilePath
: join(process.cwd(), 'src/payload.config.ts')
const jobsDirPath =
experimentalTools && experimentalTools.jobs?.jobsDirPath
? experimentalTools.jobs.jobsDirPath
: join(process.cwd(), 'src/jobs')
return createMcpHandler(
(server) => {
const enabledCollectionSlugs = Object.keys(collectionsPluginConfig || {}).filter(
(collection) => {
const fullyEnabled =
typeof collectionsPluginConfig?.[collection]?.enabled === 'boolean' &&
collectionsPluginConfig?.[collection]?.enabled
if (fullyEnabled) {
return true
}
const partiallyEnabled =
typeof collectionsPluginConfig?.[collection]?.enabled !== 'boolean' &&
((typeof collectionsPluginConfig?.[collection]?.enabled?.find === 'boolean' &&
collectionsPluginConfig?.[collection]?.enabled?.find === true) ||
(typeof collectionsPluginConfig?.[collection]?.enabled?.create === 'boolean' &&
collectionsPluginConfig?.[collection]?.enabled?.create === true) ||
(typeof collectionsPluginConfig?.[collection]?.enabled?.update === 'boolean' &&
collectionsPluginConfig?.[collection]?.enabled?.update === true) ||
(typeof collectionsPluginConfig?.[collection]?.enabled?.delete === 'boolean' &&
collectionsPluginConfig?.[collection]?.enabled?.delete === true))
if (partiallyEnabled) {
return true
}
},
)
// Collection Operation Tools
enabledCollectionSlugs.forEach((enabledCollectionSlug) => {
const collectonGlobalSetting = toolSettings?.[
`${enabledCollectionSlug}-capabilities`
] as Record<string, unknown>
const allowCreate: boolean | undefined = collectonGlobalSetting[
`${enabledCollectionSlug}-create`
] as boolean
const allowDelete: boolean | undefined = collectonGlobalSetting[
`${enabledCollectionSlug}-delete`
] as boolean
const allowFind: boolean | undefined = collectonGlobalSetting[
`${enabledCollectionSlug}-find`
] as boolean
const allowUpdate: boolean | undefined = collectonGlobalSetting[
`${enabledCollectionSlug}-update`
] as boolean
if (allowCreate) {
registerTool(
allowCreate,
`Create ${enabledCollectionSlug}`,
() =>
createResourceTool(
server,
req,
useVerboseLogs,
enabledCollectionSlug,
collectionsPluginConfig,
),
payload,
useVerboseLogs,
)
}
if (allowDelete) {
registerTool(
allowDelete,
`Delete ${enabledCollectionSlug}`,
() =>
deleteResourceTool(
server,
req,
useVerboseLogs,
enabledCollectionSlug,
collectionsPluginConfig,
),
payload,
useVerboseLogs,
)
}
if (allowFind) {
registerTool(
allowFind,
`Find ${enabledCollectionSlug}`,
() =>
findResourceTool(
server,
req,
useVerboseLogs,
enabledCollectionSlug,
collectionsPluginConfig,
),
payload,
useVerboseLogs,
)
}
if (allowUpdate) {
registerTool(
allowUpdate,
`Update ${enabledCollectionSlug}`,
() =>
updateResourceTool(
server,
req,
useVerboseLogs,
enabledCollectionSlug,
collectionsPluginConfig,
),
payload,
useVerboseLogs,
)
}
})
// Custom tools
customMCPTools.forEach((tool) => {
const camelCasedToolName = toCamelCase(tool.name)
const isToolEnabled = toolSettings.custom?.[camelCasedToolName] ?? true
registerTool(
isToolEnabled,
tool.name,
() => server.tool(tool.name, tool.description, tool.parameters, tool.handler),
payload,
useVerboseLogs,
)
})
// Experimental - Collection Schema Modfication Tools
if (
toolSettings.collections?.create &&
experimentalTools.collections?.enabled &&
isDevelopment
) {
registerTool(
toolSettings.collections.create,
'Create Collection',
() =>
createCollectionTool(server, req, useVerboseLogs, collectionsDirPath, configFilePath),
payload,
useVerboseLogs,
)
}
if (
toolSettings.collections?.delete &&
experimentalTools.collections?.enabled &&
isDevelopment
) {
registerTool(
toolSettings.collections.delete,
'Delete Collection',
() =>
deleteCollectionTool(server, req, useVerboseLogs, collectionsDirPath, configFilePath),
payload,
useVerboseLogs,
)
}
if (
toolSettings.collections?.find &&
experimentalTools.collections?.enabled &&
isDevelopment
) {
registerTool(
toolSettings.collections.find,
'Find Collection',
() => findCollectionTool(server, req, useVerboseLogs, collectionsDirPath),
payload,
useVerboseLogs,
)
}
if (
toolSettings.collections?.update &&
experimentalTools.collections?.enabled &&
isDevelopment
) {
registerTool(
toolSettings.collections.update,
'Update Collection',
() =>
updateCollectionTool(server, req, useVerboseLogs, collectionsDirPath, configFilePath),
payload,
useVerboseLogs,
)
}
// Experimental - Payload Config Modification Tools
if (toolSettings.config?.find && experimentalTools.config?.enabled && isDevelopment) {
registerTool(
toolSettings.config.find,
'Find Config',
() => findConfigTool(server, req, useVerboseLogs, configFilePath),
payload,
useVerboseLogs,
)
}
if (toolSettings.config?.update && experimentalTools.config?.enabled && isDevelopment) {
registerTool(
toolSettings.config.update,
'Update Config',
() => updateConfigTool(server, req, useVerboseLogs, configFilePath),
payload,
useVerboseLogs,
)
}
// Experimental - Job Modification Tools
if (toolSettings.jobs?.create && experimentalTools.jobs?.enabled && isDevelopment) {
registerTool(
toolSettings.jobs.create,
'Create Job',
() => createJobTool(server, req, useVerboseLogs, jobsDirPath),
payload,
useVerboseLogs,
)
}
if (toolSettings.jobs?.update && experimentalTools.jobs?.enabled && isDevelopment) {
registerTool(
toolSettings.jobs.update,
'Update Job',
() => updateJobTool(server, req, useVerboseLogs, jobsDirPath),
payload,
useVerboseLogs,
)
}
if (toolSettings.jobs?.run && experimentalTools.jobs?.enabled && isDevelopment) {
registerTool(
toolSettings.jobs.run,
'Run Job',
() => runJobTool(server, req, useVerboseLogs),
payload,
useVerboseLogs,
)
}
// Experimental - Auth Modification Tools
if (toolSettings.auth?.auth && experimentalTools.auth?.enabled && isDevelopment) {
registerTool(
toolSettings.auth.auth,
'Auth',
() => authTool(server, req, useVerboseLogs),
payload,
useVerboseLogs,
)
}
if (toolSettings.auth?.login && experimentalTools.auth?.enabled && isDevelopment) {
registerTool(
toolSettings.auth.login,
'Login',
() => loginTool(server, req, useVerboseLogs),
payload,
useVerboseLogs,
)
}
if (toolSettings.auth?.verify && experimentalTools.auth?.enabled && isDevelopment) {
registerTool(
toolSettings.auth.verify,
'Verify',
() => verifyTool(server, req, useVerboseLogs),
payload,
useVerboseLogs,
)
}
if (toolSettings.auth?.resetPassword && experimentalTools.auth?.enabled) {
registerTool(
toolSettings.auth.resetPassword,
'Reset Password',
() => resetPasswordTool(server, req, useVerboseLogs),
payload,
useVerboseLogs,
)
}
if (toolSettings.auth?.forgotPassword && experimentalTools.auth?.enabled) {
registerTool(
toolSettings.auth.forgotPassword,
'Forgot Password',
() => forgotPasswordTool(server, req, useVerboseLogs),
payload,
useVerboseLogs,
)
}
if (toolSettings.auth?.unlock && experimentalTools.auth?.enabled) {
registerTool(
toolSettings.auth.unlock,
'Unlock',
() => unlockTool(server, req, useVerboseLogs),
payload,
useVerboseLogs,
)
}
if (useVerboseLogs) {
payload.logger.info('[payload-mcp] 🚀 Tools Registered.')
}
},
{
serverInfo: serverOptions.serverInfo,
},
{
basePath: MCPHandlerOptions.basePath || '/api',
maxDuration: MCPHandlerOptions.maxDuration || 60,
redisUrl: MCPHandlerOptions.redisUrl || process.env.REDIS_URL,
verboseLogs: useVerboseLogs,
},
)
}

View File

@@ -0,0 +1,326 @@
import type {
AdminConfig,
CollectionConfigUpdates,
DatabaseConfig,
GeneralConfig,
PluginUpdates,
} from '../../types.js'
/**
* Adds a collection to the payload.config.ts file
*/
export function addCollectionToConfig(content: string, collectionName: string): string {
const capitalizedName = collectionName.charAt(0).toUpperCase() + collectionName.slice(1)
// Add import statement
const importRegex = /import.*from\s*['"]\.\/collections\/.*['"]/g
const importMatches = content.match(importRegex)
if (importMatches && importMatches.length > 0) {
const lastImport = importMatches[importMatches.length - 1]
const newImport = `import { ${capitalizedName} } from './collections/${capitalizedName}'`
// Check if import already exists
if (lastImport && !content.includes(newImport)) {
content = content.replace(lastImport, `${lastImport}\n${newImport}`)
}
} else {
// Add import after existing imports
const importInsertPoint = content.indexOf("import sharp from 'sharp'")
if (importInsertPoint !== -1) {
const lineEnd = content.indexOf('\n', importInsertPoint)
const newImport = `import { ${capitalizedName} } from './collections/${capitalizedName}'`
content = content.slice(0, lineEnd + 1) + newImport + '\n' + content.slice(lineEnd + 1)
}
}
// Add to collections array
const collectionsRegex = /collections:\s*\[([\s\S]*?)\]/
const collectionsMatch = content.match(collectionsRegex)
if (collectionsMatch && collectionsMatch[1]) {
const collectionsContent = collectionsMatch[1].trim()
if (!collectionsContent.includes(capitalizedName)) {
const newCollections = collectionsContent
? `${collectionsContent}, ${capitalizedName}`
: capitalizedName
content = content.replace(collectionsRegex, `collections: [${newCollections}]`)
}
}
return content
}
/**
* Removes a collection from the payload.config.ts file
*/
export function removeCollectionFromConfig(content: string, collectionName: string): string {
const capitalizedName = collectionName.charAt(0).toUpperCase() + collectionName.slice(1)
// Remove import statement
const importRegex = new RegExp(
`import\\s*{\\s*${capitalizedName}\\s*}\\s*from\\s*['"]\\./collections/${capitalizedName}['"]\\s*\\n?`,
'g',
)
content = content.replace(importRegex, '')
// Remove from collections array
const collectionsRegex = /collections:\s*\[([\s\S]*?)\]/
const collectionsMatch = content.match(collectionsRegex)
if (collectionsMatch && collectionsMatch[1]) {
let collectionsContent = collectionsMatch[1]
// Remove the collection name and clean up commas
collectionsContent = collectionsContent.replace(
new RegExp(`\\s*,?\\s*${capitalizedName}\\s*,?`, 'g'),
'',
)
collectionsContent = collectionsContent.replace(/,\s*,/g, ',') // Remove double commas
collectionsContent = collectionsContent.replace(/^\s*,|,\s*$/g, '') // Remove leading/trailing commas
content = content.replace(collectionsRegex, `collections: [${collectionsContent}]`)
}
// Clean up any double newlines from removed imports
content = content.replace(/\n{3,}/g, '\n\n')
return content
}
/**
* Updates admin configuration in payload.config.ts
*/
export function updateAdminConfig(content: string, adminConfig: AdminConfig): string {
const adminRegex = /admin:\s*\{([^}]*)\}/
const adminMatch = content.match(adminRegex)
if (adminMatch && adminMatch[1]) {
let adminContent = adminMatch[1]
// Update specific admin properties
if (adminConfig.user) {
if (adminContent.includes('user:')) {
adminContent = adminContent.replace(/user:[^,}]*/, `user: ${adminConfig.user}.slug`)
} else {
adminContent = `\n user: ${adminConfig.user}.slug,${adminContent}`
}
}
if (adminConfig.meta) {
const metaConfig = Object.entries(adminConfig.meta)
.map(([key, value]) => ` ${key}: '${value}'`)
.join(',\n')
if (adminContent.includes('meta:')) {
adminContent = adminContent.replace(/meta:\s*\{[^}]*\}/, `meta: {\n${metaConfig}\n }`)
} else {
adminContent = `${adminContent}\n meta: {\n${metaConfig}\n },`
}
}
content = content.replace(adminRegex, `admin: {${adminContent}\n }`)
} else {
// Add admin config if it doesn't exist
const adminConfigEntries = []
if (adminConfig.user) {
adminConfigEntries.push(` user: ${adminConfig.user}.slug`)
}
if (adminConfig.meta) {
const metaConfig = Object.entries(adminConfig.meta)
.map(([key, value]) => ` ${key}: '${value}'`)
.join(',\n')
adminConfigEntries.push(` meta: {\n${metaConfig}\n }`)
}
const adminConfigString = `admin: {\n${adminConfigEntries.join(',\n')}\n },`
content = content.replace(
/export default buildConfig\(\{/,
`export default buildConfig({\n ${adminConfigString}`,
)
}
return content
}
/**
* Updates database configuration in payload.config.ts
*/
export function updateDatabaseConfig(content: string, databaseConfig: DatabaseConfig): string {
if (databaseConfig.type === 'mongodb') {
// Update to MongoDB adapter
const dbRegex = /db:[^,}]*(?:,|\})/
const mongoImportRegex = /import.*mongooseAdapter.*from.*@payloadcms\/db-mongodb.*/
if (!content.match(mongoImportRegex)) {
content = content.replace(
/(import.*from.*payload.*\n)/,
`$1import { mongooseAdapter } from '@payloadcms/db-mongodb'\n`,
)
}
const dbConfig = `db: mongooseAdapter({\n url: process.env.DATABASE_URI || '${databaseConfig.url || ''}',\n })`
content = content.replace(dbRegex, `${dbConfig},`)
}
return content
}
/**
* Updates plugins configuration in payload.config.ts
*/
export function updatePluginsConfig(content: string, pluginUpdates: PluginUpdates): string {
// Add plugin imports
if (pluginUpdates.add) {
pluginUpdates.add.forEach((pluginImport: string) => {
if (!content.includes(pluginImport)) {
content = content.replace(/(import.*from.*payload.*\n)/, `$1${pluginImport}\n`)
}
})
}
// Handle plugins array
const pluginsRegex = /plugins:\s*\[([\s\S]*?)\]/
const pluginsMatch = content.match(pluginsRegex)
if (pluginsMatch && pluginsMatch[1]) {
let pluginsContent = pluginsMatch[1]
// Remove plugins
if (pluginUpdates.remove) {
pluginUpdates.remove.forEach((pluginName: string) => {
const pluginRegex = new RegExp(`\\s*${pluginName}\\(\\)\\s*,?`, 'g')
pluginsContent = pluginsContent.replace(pluginRegex, '')
})
}
// Add plugins
if (pluginUpdates.add) {
pluginUpdates.add.forEach((pluginImport: string) => {
// This will match: import { PluginName } from '...';
const match = pluginImport.match(/import\s*\{\s*(\w+)\s*\}/)
if (match && match[1]) {
const pluginName = match[1]
if (!pluginsContent.includes(`${pluginName}(`)) {
pluginsContent = pluginsContent.trim()
? `${pluginsContent}\n ${pluginName}(),`
: `\n ${pluginName}(),`
}
}
})
}
content = content.replace(pluginsRegex, `plugins: [${pluginsContent}\n ]`)
}
return content
}
/**
* Updates general configuration options in payload.config.ts
*/
export function updateGeneralConfig(content: string, generalConfig: GeneralConfig): string {
// Update various general configuration options
Object.entries(generalConfig).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
const configRegex = new RegExp(`${key}:\\s*[^,}]*`, 'g')
if (content.match(configRegex)) {
if (typeof value === 'string') {
content = content.replace(configRegex, `${key}: '${value}'`)
} else if (typeof value === 'boolean') {
content = content.replace(configRegex, `${key}: ${value}`)
} else if (typeof value === 'object') {
content = content.replace(configRegex, `${key}: ${JSON.stringify(value, null, 2)}`)
}
} else {
// Add new config option
const configValue =
typeof value === 'string'
? `'${value}'`
: typeof value === 'object'
? JSON.stringify(value, null, 2)
: value
content = content.replace(
/export default buildConfig\(\{/,
`export default buildConfig({\n ${key}: ${configValue},`,
)
}
}
})
return content
}
/**
* Updates collection-level configuration in a collection file
*/
export function updateCollectionConfig(
content: string,
updates: CollectionConfigUpdates,
collectionName: string,
): string {
let updatedContent = content
if (updates.slug) {
updatedContent = updatedContent.replace(/slug:\s*'[^']*'/, `slug: '${updates.slug}'`)
}
if (updates.access) {
const accessRegex = /access:\s*\{[^}]*\}/
if (updatedContent.match(accessRegex)) {
// Update existing access config
Object.entries(updates.access).forEach(([key, value]) => {
if (value !== undefined) {
updatedContent = updatedContent.replace(
new RegExp(`${key}:\\s*[^,}]*`),
`${key}: ${value}`,
)
}
})
} else {
// Add access config
const accessConfig = Object.entries(updates.access)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => ` ${key}: ${value}`)
.join(',\n')
updatedContent = updatedContent.replace(
/slug:\s*'[^']*',/,
`slug: '${collectionName}',\n access: {\n${accessConfig}\n },`,
)
}
}
if (updates.timestamps !== undefined) {
if (updatedContent.includes('timestamps:')) {
updatedContent = updatedContent.replace(
/timestamps:[^,}]*/,
`timestamps: ${updates.timestamps}`,
)
} else {
updatedContent = updatedContent.replace(
/fields:\s*\[/,
`timestamps: ${updates.timestamps},\n fields: [`,
)
}
}
if (updates.versioning !== undefined) {
if (updatedContent.includes('versioning:')) {
updatedContent = updatedContent.replace(
/versioning:[^,}]*/,
`versioning: ${updates.versioning}`,
)
} else {
updatedContent = updatedContent.replace(
/fields:\s*\[/,
`versioning: ${updates.versioning},\n fields: [`,
)
}
}
return updatedContent
}

View File

@@ -0,0 +1,3 @@
export const toCamelCase = (str: string): string => {
return str.replace(/-([a-z])/g, (match, letter) => letter.toUpperCase())
}

View File

@@ -0,0 +1,158 @@
type FieldDefinition = {
description?: string
name: string
options?: { label: string; value: string }[]
position?: 'main' | 'sidebar'
required?: boolean
type: string
}
type FieldModification = {
changes: {
description?: string
options?: { label: string; value: string }[]
position?: 'main' | 'sidebar'
required?: boolean
type?: string
}
fieldName: string
}
/**
* Adds new fields to a collection file content
*/
export function addFieldsToCollection(content: string, newFields: FieldDefinition[]): string {
// Find the fields array closing bracket
const fieldsRegex = /fields:\s*\[([\s\S]*?)\]\s*(?:,\s*)?\}/
const match = content.match(fieldsRegex)
if (!match) {
throw new Error('Could not find fields array in collection file')
}
// Generate new field definitions
const newFieldDefinitions = newFields
.map((field) => {
const fieldConfig = []
fieldConfig.push(` {`)
fieldConfig.push(` name: '${field.name}',`)
fieldConfig.push(` type: '${field.type}',`)
if (field.required) {
fieldConfig.push(` required: true,`)
}
if (field.description || field.position) {
fieldConfig.push(` admin: {`)
if (field.description) {
fieldConfig.push(` description: '${field.description}',`)
}
if (field.position) {
fieldConfig.push(` position: '${field.position}',`)
}
fieldConfig.push(` },`)
}
if (field.options && field.type === 'select') {
fieldConfig.push(` options: [`)
field.options.forEach((option: { label: string; value: string }) => {
fieldConfig.push(` { label: '${option.label}', value: '${option.value}' },`)
})
fieldConfig.push(` ],`)
}
fieldConfig.push(` },`)
return fieldConfig.join('\n')
})
.join('\n')
// Add new fields before the closing bracket
const existingFields = match[1] || ''
const hasTrailingComma = existingFields.trim().endsWith(',')
const separator = hasTrailingComma ? '\n' : ',\n'
return content.replace(
fieldsRegex,
`fields: [${existingFields}${separator}${newFieldDefinitions}\n ],
}`,
)
}
/**
* Removes fields from a collection file content
*/
export function removeFieldsFromCollection(content: string, fieldNames: string[]): string {
let updatedContent = content
fieldNames.forEach((fieldName) => {
// Create regex to match the field definition
const fieldRegex = new RegExp(
`\\s*{[^}]*name:\\s*['"]${fieldName}['"][^}]*}[^}]*(?:},?|,?\\s*})`,
'gs',
)
updatedContent = updatedContent.replace(fieldRegex, '')
})
// Clean up any double commas or trailing commas
updatedContent = updatedContent.replace(/,\s*,/g, ',')
updatedContent = updatedContent.replace(/,\s*\]/g, '\n ]')
return updatedContent
}
/**
* Modifies existing fields in a collection file content
*/
export function modifyFieldsInCollection(
content: string,
modifications: FieldModification[],
): string {
let updatedContent = content
modifications.forEach((mod) => {
const { changes, fieldName } = mod
// Find the field definition
const fieldRegex = new RegExp(`({[^}]*name:\\s*['"]${fieldName}['"][^}]*})`, 'gs')
const fieldMatch = updatedContent.match(fieldRegex)
if (fieldMatch) {
let fieldDef = fieldMatch[0]
// Apply changes
if (changes.type) {
fieldDef = fieldDef.replace(/type:\s*'[^']*'/, `type: '${changes.type}'`)
}
if (changes.required !== undefined) {
if (fieldDef.includes('required:')) {
fieldDef = fieldDef.replace(/required:[^,]*/, `required: ${changes.required}`)
} else {
fieldDef = fieldDef.replace(
/type:\s*'[^']*',/,
`type: '${changes.type}',\n required: ${changes.required},`,
)
}
}
if (changes.description) {
const adminRegex = /admin:\s*\{[^}]*\}/
if (fieldDef.match(adminRegex)) {
fieldDef = fieldDef.replace(
/description:\s*'[^']*'/,
`description: '${changes.description}'`,
)
} else {
fieldDef = fieldDef.replace(
/\},?\s*$/,
`,\n admin: {\n description: '${changes.description}',\n },\n }`,
)
}
}
updatedContent = updatedContent.replace(fieldRegex, fieldDef)
}
})
return updatedContent
}

View File

@@ -0,0 +1,417 @@
import type { CollectionConfig } from 'payload'
import { existsSync } from 'fs'
import { join } from 'path'
export type ValidationType = 'collection' | 'task' | 'workflow'
export interface ValidationResult<T = unknown> {
config?: T
error?: string
success: boolean
}
// Custom task config interface that matches what we're creating
export interface TaskConfig {
handler: (args: {
input: Record<string, unknown>
job: Record<string, unknown>
tasks: Record<string, unknown>
}) => Record<string, unknown>
inputSchema?: Array<{
label?: string
name: string
options?: Array<{ label: string; value: string }>
required?: boolean
type: string
}>
label?: string
outputSchema?: Array<{
label?: string
name: string
options?: Array<{ label: string; value: string }>
required?: boolean
type: string
}>
retries?: number
slug: string
}
// Custom workflow config interface that matches what we're creating
export interface WorkflowConfig {
handler: (args: {
input: Record<string, unknown>
job: Record<string, unknown>
tasks: Record<string, unknown>
}) => void
inputSchema?: Array<{
label?: string
name: string
options?: Array<{ label: string; value: string }>
required?: boolean
type: string
}>
label?: string
queue?: string
retries?: number
slug: string
}
/**
* Generic validation function for Payload configuration files
* @param fileName - The name of the file (e.g., 'Users.ts', 'my-task.ts')
* @param type - The type of validation to perform ('collection', 'task', or 'workflow')
* @returns Object containing success status and any error messages
*/
export const validatePayloadFile = async <T = CollectionConfig | TaskConfig | WorkflowConfig>(
fileName: string,
type: ValidationType,
): Promise<ValidationResult<T>> => {
try {
const basePath = type === 'collection' ? 'collections' : type === 'task' ? 'tasks' : 'workflows'
const fullPath = join(process.cwd(), 'src', basePath)
const filePath = join(fullPath, fileName)
// Check if file exists
if (!existsSync(filePath)) {
return {
error: `${type} file does not exist: ${fileName}`,
success: false,
}
}
// Clear require cache to ensure fresh import
delete require.cache[filePath]
// Use relative path for webpack compatibility
const moduleName = fileName.replace('.ts', '')
const relativePath = `../${basePath}/${moduleName}`
// Dynamic import with relative path
const importedModule = await import(/* webpackIgnore: true */ relativePath)
// Get the configuration based on type
let config: T | undefined
if (type === 'collection') {
config = getCollectionConfig(importedModule, moduleName) as T
} else if (type === 'task') {
config = getTaskConfig(importedModule) as T
} else if (type === 'workflow') {
config = getWorkflowConfig(importedModule) as T
}
if (!config) {
return {
error: `${type} file does not export a valid ${type} config`,
success: false,
}
}
// Validate the configuration
let validationResult: ValidationResult<unknown>
if (type === 'collection') {
validationResult = validateCollectionConfig(config as unknown as CollectionConfig)
} else if (type === 'task') {
validationResult = validateTaskConfig(config as unknown as TaskConfig)
} else if (type === 'workflow') {
validationResult = validateWorkflowConfig(config as unknown as WorkflowConfig)
} else {
return {
error: `Unknown validation type: ${type}`,
success: false,
}
}
if (!validationResult.success) {
return validationResult as ValidationResult<T>
}
return {
config,
success: true,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error during validation'
return {
error: `Failed to validate ${type} file: ${errorMessage}`,
success: false,
}
}
}
/**
* Extract collection configuration from module exports
*/
function getCollectionConfig(
importedModule: Record<string, unknown>,
moduleName: string,
): CollectionConfig | undefined {
if (importedModule.default) {
return importedModule.default as CollectionConfig
}
if (importedModule[moduleName]) {
return importedModule[moduleName] as CollectionConfig
}
return undefined
}
/**
* Extract task configuration from module exports
*/
function getTaskConfig(importedModule: Record<string, unknown>): TaskConfig | undefined {
// First check for default export
if (importedModule.default) {
return importedModule.default as TaskConfig
}
// Look for named exports ending with "Task"
const exportNames = Object.keys(importedModule)
const taskExport = exportNames.find((name) => name.endsWith('Task'))
if (taskExport) {
return importedModule[taskExport] as TaskConfig
}
return undefined
}
/**
* Extract workflow configuration from module exports
*/
function getWorkflowConfig(importedModule: Record<string, unknown>): undefined | WorkflowConfig {
// First check for default export
if (importedModule.default) {
return importedModule.default as WorkflowConfig
}
// Look for named exports ending with "Workflow"
const exportNames = Object.keys(importedModule)
const workflowExport = exportNames.find((name) => name.endsWith('Workflow'))
if (workflowExport) {
return importedModule[workflowExport] as WorkflowConfig
}
return undefined
}
/**
* Validate collection configuration structure
*/
function validateCollectionConfig(config: CollectionConfig): ValidationResult<CollectionConfig> {
if (!config || typeof config !== 'object') {
return {
error: 'Collection config is not a valid object',
success: false,
}
}
if (!config.slug || typeof config.slug !== 'string') {
return {
error: 'Collection config must have a valid slug property',
success: false,
}
}
// Validate each field has required properties
if (config.fields) {
for (let i = 0; i < config.fields.length; i++) {
const field = config.fields[i] as Record<string, unknown>
if (!field || typeof field !== 'object') {
return {
error: `Field at index ${i} is not a valid object`,
success: false,
}
}
// Check if field has type property
if ('type' in field && field.type && typeof field.type !== 'string') {
return {
error: `Field at index ${i} has invalid type property`,
success: false,
}
}
}
}
return { config, success: true }
}
/**
* Validate task configuration structure
*/
function validateTaskConfig(config: TaskConfig): ValidationResult<TaskConfig> {
if (!config || typeof config !== 'object') {
return {
error: 'Task config is not a valid object',
success: false,
}
}
if (!config.slug || typeof config.slug !== 'string') {
return {
error: 'Task config must have a valid slug property',
success: false,
}
}
if (!config.handler || typeof config.handler !== 'function') {
return {
error: 'Task config must have a valid handler function',
success: false,
}
}
// Validate optional properties
if (config.retries !== undefined && (typeof config.retries !== 'number' || config.retries < 0)) {
return {
error: 'Task config retries must be a non-negative number',
success: false,
}
}
// Validate schemas if present
if (config.inputSchema && Array.isArray(config.inputSchema)) {
for (let i = 0; i < config.inputSchema.length; i++) {
const field = config.inputSchema[i]
if (!field || typeof field !== 'object') {
return {
error: `Input schema field at index ${i} is not a valid object`,
success: false,
}
}
if (!field.name || typeof field.name !== 'string') {
return {
error: `Input schema field at index ${i} must have a valid name property`,
success: false,
}
}
if (!field.type || typeof field.type !== 'string') {
return {
error: `Input schema field at index ${i} must have a valid type property`,
success: false,
}
}
}
}
if (config.outputSchema && Array.isArray(config.outputSchema)) {
for (let i = 0; i < config.outputSchema.length; i++) {
const field = config.outputSchema[i]
if (!field || typeof field !== 'object') {
return {
error: `Output schema field at index ${i} is not a valid object`,
success: false,
}
}
if (!field.name || typeof field.name !== 'string') {
return {
error: `Output schema field at index ${i} must have a valid name property`,
success: false,
}
}
if (!field.type || typeof field.type !== 'string') {
return {
error: `Output schema field at index ${i} must have a valid type property`,
success: false,
}
}
}
}
return { config, success: true }
}
/**
* Validate workflow configuration structure
*/
function validateWorkflowConfig(config: WorkflowConfig): ValidationResult<WorkflowConfig> {
if (!config || typeof config !== 'object') {
return {
error: 'Workflow config is not a valid object',
success: false,
}
}
if (!config.slug || typeof config.slug !== 'string') {
return {
error: 'Workflow config must have a valid slug property',
success: false,
}
}
if (!config.handler || typeof config.handler !== 'function') {
return {
error: 'Workflow config must have a valid handler function',
success: false,
}
}
// Validate optional properties
if (config.queue && typeof config.queue !== 'string') {
return {
error: 'Workflow config queue must be a string',
success: false,
}
}
if (config.retries !== undefined && (typeof config.retries !== 'number' || config.retries < 0)) {
return {
error: 'Workflow config retries must be a non-negative number',
success: false,
}
}
// Validate schema if present
if (config.inputSchema && Array.isArray(config.inputSchema)) {
for (let i = 0; i < config.inputSchema.length; i++) {
const field = config.inputSchema[i]
if (!field || typeof field !== 'object') {
return {
error: `Input schema field at index ${i} is not a valid object`,
success: false,
}
}
if (!field.name || typeof field.name !== 'string') {
return {
error: `Input schema field at index ${i} must have a valid name property`,
success: false,
}
}
if (!field.type || typeof field.type !== 'string') {
return {
error: `Input schema field at index ${i} must have a valid type property`,
success: false,
}
}
}
}
return { config, success: true }
}
// Convenience functions for backward compatibility
export const validateCollectionFile = async (
fileName: string,
): Promise<ValidationResult<CollectionConfig>> => {
return validatePayloadFile<CollectionConfig>(fileName, 'collection')
}
export const validateTaskFile = async (fileName: string): Promise<ValidationResult<TaskConfig>> => {
return validatePayloadFile<TaskConfig>(fileName, 'task')
}
export const validateWorkflowFile = async (
fileName: string,
): Promise<ValidationResult<WorkflowConfig>> => {
return validatePayloadFile<WorkflowConfig>(fileName, 'workflow')
}

View File

@@ -0,0 +1,32 @@
/**
* Validates collection-specific data for resource creation
*/
export function validateCollectionData(
collection: string,
data: Record<string, unknown>,
availableCollections: string[],
): null | string {
// Check if collection exists
if (!availableCollections.includes(collection)) {
return `Unknown collection: ${collection}. Available collections: ${availableCollections.join(', ')}`
}
if (!data || typeof data !== 'object' || Object.keys(data).length === 0) {
return `Collection "${collection}" requires data to be provided`
}
return null
}
/**
* Checks if a collection slug is valid
*/
export function validateCollectionSlug(
collection: string,
collections: Partial<Record<string, true>>,
) {
const collectionSlugs = Object.keys(collections)
if (!collectionSlugs.includes(collection)) {
return `Collection "${collection}" is not valid`
}
}

View File

@@ -0,0 +1,16 @@
export const registerTool = (
isEnabled: boolean | undefined,
toolType: string,
registrationFn: () => void,
payload: { logger: { info: (message: string) => void } },
useVerboseLogs: boolean,
) => {
if (isEnabled) {
registrationFn()
if (useVerboseLogs) {
payload.logger.info(`[payload-mcp] ✅ ${toolType} Registered.`)
}
} else if (useVerboseLogs) {
payload.logger.info(`[payload-mcp] ⏭️ ${toolType} Skipped.`)
}
}

View File

@@ -0,0 +1,69 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import { toolSchemas } from '../schemas.js'
export const authTool = (server: McpServer, req: PayloadRequest, verboseLogs: boolean) => {
const tool = async (headers?: string) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info('[payload-mcp] Checking authentication status')
}
try {
// Parse custom headers if provided, otherwise use empty headers
let authHeaders = new Headers()
if (headers) {
try {
const parsedHeaders = JSON.parse(headers)
authHeaders = new Headers(parsedHeaders)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Using custom headers: ${headers}`)
}
} catch (_ignore) {
payload.logger.warn(`[payload-mcp] Invalid headers JSON: ${headers}, using empty headers`)
}
}
const result = await payload.auth({
headers: authHeaders,
})
if (verboseLogs) {
payload.logger.info('[payload-mcp] Authentication check completed successfully')
}
return {
content: [
{
type: 'text' as const,
text: `# Authentication Status\n\n\`\`\`json\n${JSON.stringify(result, null, 2)}\n\`\`\``,
},
],
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
payload.logger.error(`[payload-mcp] Error checking authentication: ${errorMessage}`)
return {
content: [
{
type: 'text' as const,
text: `❌ **Error checking authentication**: ${errorMessage}`,
},
],
}
}
}
server.tool(
'auth',
toolSchemas.auth.description,
toolSchemas.auth.parameters.shape,
async ({ headers }) => {
return await tool(headers)
},
)
}

View File

@@ -0,0 +1,68 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import { toolSchemas } from '../schemas.js'
export const forgotPasswordTool = (
server: McpServer,
req: PayloadRequest,
verboseLogs: boolean,
) => {
const tool = async (collection: string, email: string, disableEmail: boolean = false) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Sending password reset email for user: ${email} in collection: ${collection}`,
)
}
try {
const result = await payload.forgotPassword({
collection,
data: {
email,
},
disableEmail,
})
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Password reset email sent successfully for user: ${email}`,
)
}
return {
content: [
{
type: 'text' as const,
text: `# Password Reset Email Sent\n\n**User:** ${email}\n**Collection:** ${collection}\n**Email Disabled:** ${disableEmail}\n\n\`\`\`json\n${JSON.stringify(result, null, 2)}\n\`\`\``,
},
],
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
payload.logger.error(
`[payload-mcp] Error sending password reset email for user ${email}: ${errorMessage}`,
)
return {
content: [
{
type: 'text' as const,
text: `❌ **Error sending password reset email for user "${email}"**: ${errorMessage}`,
},
],
}
}
}
server.tool(
'forgotPassword',
toolSchemas.forgotPassword.description,
toolSchemas.forgotPassword.parameters.shape,
async ({ collection, disableEmail, email }) => {
return await tool(collection, email, disableEmail)
},
)
}

View File

@@ -0,0 +1,70 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import { toolSchemas } from '../schemas.js'
export const loginTool = (server: McpServer, req: PayloadRequest, verboseLogs: boolean) => {
const tool = async (
collection: string,
email: string,
password: string,
depth: number = 0,
overrideAccess: boolean = false,
showHiddenFields: boolean = false,
) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Attempting login for user: ${email} in collection: ${collection}`,
)
}
try {
const result = await payload.login({
collection,
data: {
email,
password,
},
depth,
overrideAccess,
showHiddenFields,
})
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Login successful for user: ${email}`)
}
return {
content: [
{
type: 'text' as const,
text: `# Login Successful\n\n**User:** ${email}\n**Collection:** ${collection}\n\n\`\`\`json\n${JSON.stringify(result, null, 2)}\n\`\`\``,
},
],
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
payload.logger.error(`[payload-mcp] Login failed for user ${email}: ${errorMessage}`)
return {
content: [
{
type: 'text' as const,
text: `❌ **Login failed for user "${email}"**: ${errorMessage}`,
},
],
}
}
}
server.tool(
'login',
toolSchemas.login.description,
toolSchemas.login.parameters.shape,
async ({ collection, depth, email, overrideAccess, password, showHiddenFields }) => {
return await tool(collection, email, password, depth, overrideAccess, showHiddenFields)
},
)
}

View File

@@ -0,0 +1,59 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import { toolSchemas } from '../schemas.js'
export const resetPasswordTool = (server: McpServer, req: PayloadRequest, verboseLogs: boolean) => {
const tool = async (collection: string, token: string, password: string) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Resetting password for user in collection: ${collection}`)
}
try {
const result = await payload.resetPassword({
collection,
data: {
password,
token,
},
overrideAccess: true,
})
if (verboseLogs) {
payload.logger.info('[payload-mcp] Password reset completed successfully')
}
return {
content: [
{
type: 'text' as const,
text: `# Password Reset Successful\n\n**Collection:** ${collection}\n**Token:** ${token}\n\n\`\`\`json\n${JSON.stringify(result, null, 2)}\n\`\`\``,
},
],
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
payload.logger.error(`[payload-mcp] Error resetting password: ${errorMessage}`)
return {
content: [
{
type: 'text' as const,
text: `❌ **Error resetting password**: ${errorMessage}`,
},
],
}
}
}
server.tool(
'resetPassword',
toolSchemas.resetPassword.description,
toolSchemas.resetPassword.parameters.shape,
async ({ collection, password, token }) => {
return await tool(collection, token, password)
},
)
}

View File

@@ -0,0 +1,62 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import { toolSchemas } from '../schemas.js'
export const unlockTool = (server: McpServer, req: PayloadRequest, verboseLogs: boolean) => {
const tool = async (collection: string, email: string) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Unlocking user account for user: ${email} in collection: ${collection}`,
)
}
try {
const result = await payload.unlock({
collection,
data: {
email,
},
overrideAccess: true,
})
if (verboseLogs) {
payload.logger.info(`[payload-mcp] User account unlocked successfully for user: ${email}`)
}
return {
content: [
{
type: 'text' as const,
text: `# User Account Unlocked\n\n**User:** ${email}\n**Collection:** ${collection}\n\n\`\`\`json\n${JSON.stringify(result, null, 2)}\n\`\`\``,
},
],
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
payload.logger.error(
`[payload-mcp] Error unlocking user account for user ${email}: ${errorMessage}`,
)
return {
content: [
{
type: 'text' as const,
text: `❌ **Error unlocking user account for user "${email}"**: ${errorMessage}`,
},
],
}
}
}
server.tool(
'unlock',
toolSchemas.unlock.description,
toolSchemas.unlock.parameters.shape,
async ({ collection, email }) => {
return await tool(collection, email)
},
)
}

View File

@@ -0,0 +1,55 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import { toolSchemas } from '../schemas.js'
export const verifyTool = (server: McpServer, req: PayloadRequest, verboseLogs: boolean) => {
const tool = async (collection: string, token: string) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Verifying user account for collection: ${collection}`)
}
try {
const result = await payload.verifyEmail({
collection,
token,
})
if (verboseLogs) {
payload.logger.info('[payload-mcp] Email verification completed successfully')
}
return {
content: [
{
type: 'text' as const,
text: `# Email Verification Successful\n\n**Collection:** ${collection}\n**Token:** ${token}\n**Result:** ${result ? 'Success' : 'Failed'}`,
},
],
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
payload.logger.error(`[payload-mcp] Error verifying email: ${errorMessage}`)
return {
content: [
{
type: 'text' as const,
text: `❌ **Error verifying email**: ${errorMessage}`,
},
],
}
}
}
server.tool(
'verify',
toolSchemas.verify.description,
toolSchemas.verify.parameters.shape,
async ({ collection, token }) => {
return await tool(collection, token)
},
)
}

View File

@@ -0,0 +1,236 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import { writeFileSync } from 'fs'
import { join } from 'path'
import { validateCollectionFile } from '../../helpers/fileValidation.js'
import { toolSchemas } from '../schemas.js'
export const createCollection = async (
req: PayloadRequest,
verboseLogs: boolean,
collectionsDirPath: string,
configFilePath: string,
collectionName: string,
collectionDescription: string | undefined,
fields: any[],
hasUpload: boolean | undefined,
) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Creating collection: ${collectionName} with ${fields.length} fields`,
)
}
const capitalizedName = collectionName.charAt(0).toUpperCase() + collectionName.slice(1)
const slug = collectionName
.replace(/([A-Z])/g, '-$1')
.toLowerCase()
.replace(/^-/, '')
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Generated slug: ${slug} for collection: ${collectionName}`)
}
// Generate TypeScript field definitions more systematically
const generateFieldDefinition = (field: any) => {
const fieldConfig = []
fieldConfig.push(` {`)
fieldConfig.push(` name: '${field.name}',`)
fieldConfig.push(` type: '${field.type}',`)
if (field.required) {
fieldConfig.push(` required: true,`)
}
if (field.description) {
fieldConfig.push(` admin: {`)
fieldConfig.push(` description: '${field.description}',`)
fieldConfig.push(` },`)
}
if (field.options && field.type === 'select') {
fieldConfig.push(` options: [`)
field.options.forEach((option: { label: string; value: string }) => {
fieldConfig.push(` { label: '${option.label}', value: '${option.value}' },`)
})
fieldConfig.push(` ],`)
}
fieldConfig.push(` },`)
return fieldConfig.join('\n')
}
const fieldDefinitions = fields.map(generateFieldDefinition).join('\n')
// Generate collection file content
const collectionContent = `import type { CollectionConfig } from 'payload'
export const ${capitalizedName}: CollectionConfig = {
slug: '${slug}',${
collectionDescription
? `
admin: {
description: '${collectionDescription}',
},`
: ''
}${
hasUpload
? `
upload: true,`
: ''
}
fields: [
${fieldDefinitions}
],
}
`
try {
// Validate the collection name and path
const fileName = `${capitalizedName}.ts`
const filePath = join(collectionsDirPath, fileName)
// Security check: ensure we're working with the collections directory
if (!filePath.startsWith(collectionsDirPath)) {
payload.logger.error(`[payload-mcp] Invalid collection path attempted: ${filePath}`)
return {
content: [
{
type: 'text' as const,
text: '❌ **Error**: Invalid collection path',
},
],
}
}
// Check if file already exists
try {
const fs = await import('fs')
if (fs.existsSync(filePath)) {
return {
content: [
{
type: 'text' as const,
text: `❌ **Error**: Collection file already exists: ${fileName}`,
},
],
}
}
} catch (_ignore) {
// File doesn't exist, which is what we want
}
// Write the collection file
writeFileSync(filePath, collectionContent, 'utf8')
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Successfully created collection file: ${filePath}`)
}
// Validate the generated file
const validationResult = await validateCollectionFile(fileName)
if (validationResult.error) {
return {
content: [
{
type: 'text' as const,
text: `❌ **Error**: Generated collection has validation issues:\n\n${validationResult.error}`,
},
],
}
}
return {
content: [
{
type: 'text' as const,
text: `✅ **Collection created successfully!**
**File**: \`${fileName}\`
**Collection Config:**
\`\`\`typescript
${collectionContent}
\`\`\``,
},
],
}
} catch (error) {
const errorMessage = (error as Error).message
payload.logger.error(`[payload-mcp] Error creating collection: ${errorMessage}`)
return {
content: [
{
type: 'text' as const,
text: `❌ **Error creating collection**: ${errorMessage}`,
},
],
}
}
}
export const createCollectionTool = (
server: McpServer,
req: PayloadRequest,
verboseLogs: boolean,
collectionsDirPath: string,
configFilePath: string,
) => {
const tool = async (
collectionName: string,
collectionDescription?: string,
fields: any[] = [],
hasUpload?: boolean,
) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Creating collection: ${collectionName}, fields: ${fields.length}, upload: ${hasUpload}`,
)
}
try {
const result = await createCollection(
req,
verboseLogs,
collectionsDirPath,
configFilePath,
collectionName,
collectionDescription,
fields,
hasUpload,
)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Collection creation completed for: ${collectionName}`)
}
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
payload.logger.error(
`[payload-mcp] Error creating collection ${collectionName}: ${errorMessage}`,
)
return {
content: [
{
type: 'text' as const,
text: `Error creating collection "${collectionName}": ${errorMessage}`,
},
],
}
}
}
server.tool(
'createCollection',
toolSchemas.createCollection.description,
toolSchemas.createCollection.parameters.shape,
async ({ collectionDescription, collectionName, fields, hasUpload }) => {
return await tool(collectionName, collectionDescription, fields, hasUpload)
},
)
}

View File

@@ -0,0 +1,227 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import { readFileSync, unlinkSync, writeFileSync } from 'fs'
import { join } from 'path'
import { toolSchemas } from '../schemas.js'
// Helper function for removing collection from config
const removeCollectionFromConfig = (configContent: string, collectionName: string): string => {
// Simple implementation - find and remove the collection import and reference
let updatedContent = configContent
// Remove import statement
const importRegex = new RegExp(
`import\\s*{\\s*${collectionName}\\s*}\\s*from\\s*['"]\\./collections/${collectionName}['"];?\\s*\\n?`,
'g',
)
updatedContent = updatedContent.replace(importRegex, '')
// Remove from collections array
const collectionsRegex = new RegExp(`\\s*${collectionName},?\\s*`, 'g')
updatedContent = updatedContent.replace(collectionsRegex, '')
return updatedContent
}
export const deleteCollection = (
req: PayloadRequest,
verboseLogs: boolean,
collectionsDirPath: string,
configFilePath: string,
collectionName: string,
confirmDeletion: boolean,
updateConfig: boolean,
) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Attempting to delete collection: ${collectionName}`)
}
if (!confirmDeletion) {
payload.logger.warn(`[payload-mcp] Deletion cancelled for collection: ${collectionName}`)
return {
content: [
{
type: 'text' as const,
text: `❌ **Deletion cancelled**. Set confirmDeletion to true to proceed with deleting collection "${collectionName}".`,
},
],
}
}
const capitalizedName = collectionName.charAt(0).toUpperCase() + collectionName.slice(1)
const collectionFilePath = join(collectionsDirPath, `${capitalizedName}.ts`)
// Security check: ensure we're working with the collections directory
if (!collectionFilePath.startsWith(collectionsDirPath)) {
payload.logger.error(`[payload-mcp] Invalid collection path attempted: ${collectionFilePath}`)
return {
content: [
{
type: 'text' as const,
text: '❌ **Error**: Invalid collection path',
},
],
}
}
try {
// Check if collection file exists
let fileExists = false
try {
readFileSync(collectionFilePath, 'utf8')
fileExists = true
} catch {
payload.logger.warn(`[payload-mcp] Collection file does not exist: ${collectionFilePath}`)
}
// Read current config if we need to update it
let configContent = ''
let configExists = false
if (updateConfig) {
try {
configContent = readFileSync(configFilePath, 'utf8')
configExists = true
} catch {
payload.logger.warn(`[payload-mcp] Config file does not exist: ${configFilePath}`)
}
}
let responseText = ''
let operationsPerformed = 0
// Delete the collection file
if (fileExists) {
try {
unlinkSync(collectionFilePath)
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Successfully deleted collection file: ${collectionFilePath}`,
)
}
responseText += `✅ Deleted collection file: \`${capitalizedName}.ts\`\n`
operationsPerformed++
} catch (error) {
const errorMessage = (error as Error).message
payload.logger.error(`[payload-mcp] Error deleting collection file: ${errorMessage}`)
responseText += `❌ Error deleting collection file: ${errorMessage}\n`
}
} else {
responseText += `⚠️ Collection file not found: \`${capitalizedName}.ts\`\n`
}
// Update the config file if requested and it exists
if (updateConfig && configExists) {
try {
const updatedConfigContent = removeCollectionFromConfig(configContent, capitalizedName)
writeFileSync(configFilePath, updatedConfigContent, 'utf8')
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Successfully updated config file: ${configFilePath}`)
}
responseText += `✅ Updated payload.config.ts to remove collection reference\n`
operationsPerformed++
} catch (error) {
const errorMessage = (error as Error).message
payload.logger.error(`[payload-mcp] Error updating config file: ${errorMessage}`)
responseText += `❌ Error updating config file: ${errorMessage}\n`
}
} else if (updateConfig && !configExists) {
responseText += `⚠️ Config file not found: payload.config.ts\n`
}
// Summary
if (operationsPerformed > 0) {
responseText += `\n✅ **Collection deletion completed!**`
} else {
responseText += `\n⚠ **No operations performed**
The collection file may not have existed or there were errors during deletion.`
}
return {
content: [
{
type: 'text' as const,
text: responseText,
},
],
}
} catch (error) {
const errorMessage = (error as Error).message
payload.logger.error(`[payload-mcp] Error during collection deletion: ${errorMessage}`)
return {
content: [
{
type: 'text' as const,
text: `❌ **Error during collection deletion**: ${errorMessage}`,
},
],
}
}
}
export const deleteCollectionTool = (
server: McpServer,
req: PayloadRequest,
verboseLogs: boolean,
collectionsDirPath: string,
configFilePath: string,
) => {
const tool = (
collectionName: string,
confirmDeletion: boolean,
updateConfig: boolean = false,
) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Deleting collection: ${collectionName}, confirmDeletion: ${confirmDeletion}, updateConfig: ${updateConfig}`,
)
}
try {
const result = deleteCollection(
req,
verboseLogs,
collectionsDirPath,
configFilePath,
collectionName,
confirmDeletion,
updateConfig,
)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Collection deletion completed for: ${collectionName}`)
}
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
payload.logger.error(
`[payload-mcp] Error deleting collection ${collectionName}: ${errorMessage}`,
)
return {
content: [
{
type: 'text' as const,
text: `Error deleting collection "${collectionName}": ${errorMessage}`,
},
],
}
}
}
server.tool(
'deleteCollection',
toolSchemas.deleteCollection.description,
toolSchemas.deleteCollection.parameters.shape,
({ collectionName, confirmDeletion, updateConfig }) => {
return tool(collectionName, confirmDeletion, updateConfig)
},
)
}

View File

@@ -0,0 +1,222 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import { readdirSync, readFileSync, statSync } from 'fs'
import { extname, join } from 'path'
import { toolSchemas } from '../schemas.js'
export const readCollections = (
req: PayloadRequest,
verboseLogs: boolean,
collectionsDirPath: string,
collectionName?: string,
includeContent: boolean = false,
includeCount: boolean = false,
) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Reading collections${collectionName ? ` for: ${collectionName}` : ''}, includeContent: ${includeContent}, includeCount: ${includeCount}`,
)
}
try {
// Read specific Collection (optional)
if (collectionName) {
const fileName = `${collectionName.charAt(0).toUpperCase() + collectionName.slice(1)}.ts`
const filePath = join(collectionsDirPath, fileName)
if (!filePath.startsWith(collectionsDirPath)) {
payload.logger.error(`[payload-mcp] Invalid collection name attempted: ${collectionName}`)
return {
content: [{ type: 'text' as const, text: 'Error: Invalid collection name' }],
}
}
try {
const content = readFileSync(filePath, 'utf8')
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Successfully read collection: ${collectionName}`)
}
return {
content: [
{
type: 'text' as const,
text: `Collection: ${collectionName}
File: ${fileName}
---
${content}`,
},
],
}
} catch (_error) {
payload.logger.warn(`[payload-mcp] Collection not found: ${collectionName}`)
return {
content: [
{
type: 'text' as const,
text: `Error: Collection '${collectionName}' not found`,
},
],
}
}
}
// Read all Collections
const files = readdirSync(collectionsDirPath)
.filter((file) => extname(file) === '.ts')
.sort()
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Found ${files.length} collection files in directory`)
}
if (files.length === 0) {
payload.logger.warn('[payload-mcp] No collection files found in src/collections directory')
return {
content: [
{
type: 'text' as const,
text: 'No collection files found in src/collections directory',
},
],
}
}
const results = []
// Build complete table as a single markdown string
let tableContent = `Found ${files.length} collection file(s):\n\n`
// Build table header
let tableHeader = '| Collection | File | Size | Modified'
let tableSeparator = '|------------|------|------|----------'
if (includeCount) {
tableHeader += ' | Documents'
tableSeparator += ' |----------'
}
tableHeader += ' |'
tableSeparator += ' |'
tableContent += tableHeader + '\n'
tableContent += tableSeparator + '\n'
for (const file of files) {
const filePath = join(collectionsDirPath, file)
const stats = statSync(filePath)
const fileSize = stats.size
const lastModified = stats.mtime
const collectionName = file.replace('.ts', '')
// Build table row
let tableRow = `| **${collectionName}** | ${file} | ${fileSize.toLocaleString()} bytes | ${lastModified.toISOString()}`
// Add document count if requested
if (includeCount) {
try {
// For now, we'll skip document counting since we don't have access to payload instance
tableRow += ' | -'
} catch (error) {
tableRow += ` | Error: ${(error as Error).message}`
}
}
tableRow += ' |'
tableContent += tableRow + '\n'
if (includeContent) {
try {
const content = readFileSync(filePath, 'utf8')
tableContent += `\n**${collectionName} Content:**\n\`\`\`typescript\n${content}\n\`\`\`\n\n`
} catch (error) {
tableContent += `\nError reading content: ${(error as Error).message}\n\n`
}
}
}
results.push({
type: 'text' as const,
text: tableContent,
})
return {
content: results,
}
} catch (error) {
const errorMessage = (error as Error).message
payload.logger.error(`[payload-mcp] Error reading collections: ${errorMessage}`)
return {
content: [
{
type: 'text' as const,
text: `❌ **Error reading collections**: ${errorMessage}`,
},
],
}
}
}
// MCP Server tool registration
export const findCollectionTool = (
server: McpServer,
req: PayloadRequest,
verboseLogs: boolean,
collectionsDirPath: string,
) => {
const tool = (
collectionName?: string,
includeContent: boolean = false,
includeCount: boolean = false,
) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Finding collections${collectionName ? ` for: ${collectionName}` : ''}, includeContent: ${includeContent}, includeCount: ${includeCount}`,
)
}
try {
const result = readCollections(
req,
verboseLogs,
collectionsDirPath,
collectionName,
includeContent,
includeCount,
)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Collection search completed`)
}
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
payload.logger.error(`[payload-mcp] Error finding collections: ${errorMessage}`)
return {
content: [
{
type: 'text' as const,
text: `Error finding collections: ${errorMessage}`,
},
],
}
}
}
server.tool(
'findCollections',
toolSchemas.findCollections.description,
toolSchemas.findCollections.parameters.shape,
({ collectionName, includeContent, includeCount }) => {
return tool(collectionName, includeContent, includeCount)
},
)
}

View File

@@ -0,0 +1,288 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import { readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import {
addFieldsToCollection,
modifyFieldsInCollection,
removeFieldsFromCollection,
} from '../../helpers/fields.js'
import { validateCollectionFile } from '../../helpers/fileValidation.js'
import { toolSchemas } from '../schemas.js'
export const updateCollection = async (
req: PayloadRequest,
verboseLogs: boolean,
collectionsDirPath: string,
configFilePath: string,
collectionName: string,
updateType: string,
newFields?: any[],
fieldNamesToRemove?: string[],
fieldModifications?: any[],
configUpdates?: any,
newContent?: string,
) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Updating collection: ${collectionName}, updateType: ${updateType}`,
)
}
const capitalizedName = collectionName.charAt(0).toUpperCase() + collectionName.slice(1)
const fileName = `${capitalizedName}.ts`
const filePath = join(collectionsDirPath, fileName)
// Security check: ensure we're working with the collections directory
if (!filePath.startsWith(collectionsDirPath)) {
payload.logger.error(`[payload-mcp] Invalid collection path attempted: ${filePath}`)
return {
content: [
{
type: 'text' as const,
text: '❌ **Error**: Invalid collection path',
},
],
}
}
try {
// Check if collection file exists
let currentContent: string
try {
currentContent = readFileSync(filePath, 'utf8')
} catch (_ignore) {
return {
content: [
{
type: 'text' as const,
text: `❌ **Error**: Collection file not found: ${fileName}`,
},
],
}
}
let updatedContent: string
let updateSummary: string[] = []
switch (updateType) {
case 'add_field':
if (!newFields || newFields.length === 0) {
return {
content: [
{
type: 'text' as const,
text: '❌ **Error**: No fields provided for add_field update type',
},
],
}
}
updatedContent = addFieldsToCollection(currentContent, newFields)
updateSummary = newFields.map((field: any) => `Added field: ${field.name} (${field.type})`)
break
case 'modify_field':
if (!fieldModifications || fieldModifications.length === 0) {
return {
content: [
{
type: 'text' as const,
text: '❌ **Error**: No field modifications provided for modify_field update type',
},
],
}
}
updatedContent = modifyFieldsInCollection(currentContent, fieldModifications)
updateSummary = fieldModifications.map((mod: any) => `Modified field: ${mod.fieldName}`)
break
case 'remove_field':
if (!fieldNamesToRemove || fieldNamesToRemove.length === 0) {
return {
content: [
{
type: 'text' as const,
text: '❌ **Error**: No field names provided for remove_field update type',
},
],
}
}
updatedContent = removeFieldsFromCollection(currentContent, fieldNamesToRemove)
updateSummary = fieldNamesToRemove.map((fieldName: string) => `Removed field: ${fieldName}`)
break
case 'replace_content':
if (!newContent) {
return {
content: [
{
type: 'text' as const,
text: '❌ **Error**: No new content provided for replace_content update type',
},
],
}
}
updatedContent = newContent
updateSummary = ['Replaced entire collection content']
break
case 'update_config':
if (!configUpdates) {
return {
content: [
{
type: 'text' as const,
text: '❌ **Error**: No config updates provided for update_config update type',
},
],
}
}
// For now, we'll use a simple approach since the config helper might not have this functionality
updatedContent = currentContent
updateSummary = Object.keys(configUpdates).map((key) => `Updated config: ${key}`)
break
default:
return {
content: [
{
type: 'text' as const,
text: `❌ **Error**: Unknown update type: ${updateType}`,
},
],
}
}
// Write the updated content back to the file
writeFileSync(filePath, updatedContent, 'utf8')
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Successfully updated collection file: ${filePath}`)
}
// Validate the updated file
const validationResult = await validateCollectionFile(fileName)
if (validationResult.error) {
return {
content: [
{
type: 'text' as const,
text: `❌ **Error**: Updated collection has validation issues:\n\n${validationResult.error}`,
},
],
}
}
return {
content: [
{
type: 'text' as const,
text: `✅ **Collection updated successfully!**
**File**: \`${fileName}\`
**Update Type**: ${updateType}
**Changes Made**:
${updateSummary.map((summary) => `- ${summary}`).join('\n')}
**Updated Collection Code:**
\`\`\`typescript
${updatedContent}
\`\`\``,
},
],
}
} catch (error) {
const errorMessage = (error as Error).message
payload.logger.error(`[payload-mcp] Error updating collection: ${errorMessage}`)
return {
content: [
{
type: 'text' as const,
text: `❌ **Error updating collection**: ${errorMessage}`,
},
],
}
}
}
export const updateCollectionTool = (
server: McpServer,
req: PayloadRequest,
verboseLogs: boolean,
collectionsDirPath: string,
configFilePath: string,
) => {
const tool = async ({
collectionName,
configUpdates,
fieldModifications,
fieldNamesToRemove,
newContent,
newFields,
updateType,
}: {
collectionName: string
configUpdates?: any
fieldModifications?: any[]
fieldNamesToRemove?: string[]
newContent?: string
newFields?: any[]
updateType: string
}) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Updating collection: ${collectionName}, updateType: ${updateType}`,
)
}
try {
const result = await updateCollection(
req,
verboseLogs,
collectionsDirPath,
configFilePath,
collectionName,
updateType,
newFields,
fieldNamesToRemove,
fieldModifications,
configUpdates,
newContent,
)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Collection update completed for: ${collectionName}`)
}
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
payload.logger.error(
`[payload-mcp] Error updating collection ${collectionName}: ${errorMessage}`,
)
return {
content: [
{
type: 'text' as const,
text: `Error updating collection "${collectionName}": ${errorMessage}`,
},
],
}
}
}
server.tool(
'updateCollection',
toolSchemas.updateCollection.description,
toolSchemas.updateCollection.parameters.shape,
async (args) => {
return await tool(args)
},
)
}

View File

@@ -0,0 +1,126 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import { readFileSync, statSync } from 'fs'
import { toolSchemas } from '../schemas.js'
export const readConfigFile = (
req: PayloadRequest,
verboseLogs: boolean,
configFilePath: string,
includeMetadata: boolean = false,
) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Reading config file, includeMetadata: ${includeMetadata}`)
}
try {
// Security check: ensure we're working with the specified config file
if (!configFilePath.startsWith(process.cwd()) && !configFilePath.startsWith('/')) {
payload.logger.error(`[payload-mcp] Invalid config path attempted: ${configFilePath}`)
return {
content: [
{
type: 'text' as const,
text: '❌ **Error**: Invalid config path',
},
],
}
}
const content = readFileSync(configFilePath, 'utf8')
const stats = statSync(configFilePath)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Successfully read config file. Size: ${stats.size} bytes`)
}
let responseText = `# Payload Configuration
**File**: \`${configFilePath}\``
if (includeMetadata) {
responseText += `
**Size**: ${stats.size.toLocaleString()} bytes
**Modified**: ${stats.mtime.toISOString()}
**Created**: ${stats.birthtime.toISOString()}`
}
responseText += `
---
**Configuration Content:**
\`\`\`typescript
${content}
\`\`\``
return {
content: [
{
type: 'text' as const,
text: responseText,
},
],
}
} catch (error) {
const errorMessage = (error as Error).message
payload.logger.error(`[payload-mcp] Error reading config file: ${errorMessage}`)
return {
content: [
{
type: 'text' as const,
text: `❌ **Error reading config file**: ${errorMessage}`,
},
],
}
}
}
// MCP Server tool registration
export const findConfigTool = (
server: McpServer,
req: PayloadRequest,
verboseLogs: boolean,
configFilePath: string,
) => {
const tool = (includeMetadata: boolean = false) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Finding config, includeMetadata: ${includeMetadata}`)
}
try {
const result = readConfigFile(req, verboseLogs, configFilePath, includeMetadata)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Config search completed`)
}
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
payload.logger.error(`[payload-mcp] Error finding config: ${errorMessage}`)
return {
content: [
{
type: 'text' as const,
text: `Error finding config: ${errorMessage}`,
},
],
}
}
}
server.tool(
'findConfig',
toolSchemas.findConfig.description,
toolSchemas.findConfig.parameters.shape,
({ includeMetadata }) => {
return tool(includeMetadata)
},
)
}

View File

@@ -0,0 +1,282 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import { readFileSync, writeFileSync } from 'fs'
import {
addCollectionToConfig,
removeCollectionFromConfig,
updateAdminConfig,
updateDatabaseConfig,
updatePluginsConfig,
} from '../../helpers/config.js'
import { toolSchemas } from '../schemas.js'
export const updateConfig = (
req: PayloadRequest,
verboseLogs: boolean,
configFilePath: string,
updateType: string,
collectionName?: string,
adminConfig?: any,
databaseConfig?: any,
pluginUpdates?: any,
generalConfig?: any,
newContent?: string,
) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Updating config with update type: ${updateType}`)
}
// Security check: ensure we're working with the specified config file
if (!configFilePath.startsWith(process.cwd()) && !configFilePath.startsWith('/')) {
payload.logger.error(`[payload-mcp] Invalid config path attempted: ${configFilePath}`)
return {
content: [
{
type: 'text' as const,
text: '❌ **Error**: Invalid config path',
},
],
}
}
try {
// Read current config
let currentContent: string
try {
currentContent = readFileSync(configFilePath, 'utf8')
} catch (_ignore) {
return {
content: [
{
type: 'text' as const,
text: `❌ **Error**: Config file not found: ${configFilePath}`,
},
],
}
}
let updatedContent: string
let updateSummary: string[] = []
switch (updateType) {
case 'add_collection':
if (!collectionName) {
return {
content: [
{
type: 'text' as const,
text: '❌ **Error**: No collection name provided for add_collection update type',
},
],
}
}
updatedContent = addCollectionToConfig(currentContent, collectionName)
updateSummary = [`Added collection: ${collectionName}`]
break
case 'remove_collection':
if (!collectionName) {
return {
content: [
{
type: 'text' as const,
text: '❌ **Error**: No collection name provided for remove_collection update type',
},
],
}
}
updatedContent = removeCollectionFromConfig(currentContent, collectionName)
updateSummary = [`Removed collection: ${collectionName}`]
break
case 'replace_content':
if (!newContent) {
return {
content: [
{
type: 'text' as const,
text: '❌ **Error**: No new content provided for replace_content update type',
},
],
}
}
updatedContent = newContent
updateSummary = ['Replaced entire config content']
break
case 'update_admin':
if (!adminConfig) {
return {
content: [
{
type: 'text' as const,
text: '❌ **Error**: No admin config provided for update_admin update type',
},
],
}
}
updatedContent = updateAdminConfig(currentContent, adminConfig)
updateSummary = Object.keys(adminConfig).map((key) => `Updated admin config: ${key}`)
break
case 'update_database':
if (!databaseConfig) {
return {
content: [
{
type: 'text' as const,
text: '❌ **Error**: No database config provided for update_database update type',
},
],
}
}
updatedContent = updateDatabaseConfig(currentContent, databaseConfig)
updateSummary = Object.keys(databaseConfig).map((key) => `Updated database config: ${key}`)
break
case 'update_plugins':
if (!pluginUpdates) {
return {
content: [
{
type: 'text' as const,
text: '❌ **Error**: No plugin updates provided for update_plugins update type',
},
],
}
}
updatedContent = updatePluginsConfig(currentContent, pluginUpdates)
updateSummary = []
if (pluginUpdates.add) {
updateSummary.push(`Added plugins: ${pluginUpdates.add.join(', ')}`)
}
if (pluginUpdates.remove) {
updateSummary.push(`Removed plugins: ${pluginUpdates.remove.join(', ')}`)
}
break
default:
return {
content: [
{
type: 'text' as const,
text: `❌ **Error**: Unknown update type: ${updateType}`,
},
],
}
}
// Write the updated content back to the file
writeFileSync(configFilePath, updatedContent, 'utf8')
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Successfully updated config file: ${configFilePath}`)
}
return {
content: [
{
type: 'text' as const,
text: `✅ **Config updated successfully!**
**File**: \`${configFilePath}\`
**Update Type**: ${updateType}
**Changes Made**:
${updateSummary.map((summary) => `- ${summary}`).join('\n')}
**Updated Config Content:**
\`\`\`typescript
${updatedContent}
\`\`\``,
},
],
}
} catch (error) {
const errorMessage = (error as Error).message
payload.logger.error(`[payload-mcp] Error updating config: ${errorMessage}`)
return {
content: [
{
type: 'text' as const,
text: `❌ **Error updating config**: ${errorMessage}`,
},
],
}
}
}
export const updateConfigTool = (
server: McpServer,
req: PayloadRequest,
verboseLogs: boolean,
configFilePath: string,
) => {
const tool = ({
adminConfig,
collectionName,
databaseConfig,
generalConfig,
newContent,
pluginUpdates,
updateType,
}: {
adminConfig?: any
collectionName?: string
databaseConfig?: any
generalConfig?: any
newContent?: string
pluginUpdates?: any
updateType: string
}) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Updating config: ${updateType}`)
}
try {
const result = updateConfig(
req,
verboseLogs,
configFilePath,
updateType,
collectionName,
adminConfig,
databaseConfig,
pluginUpdates,
generalConfig,
newContent,
)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Config update completed for: ${updateType}`)
}
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
payload.logger.error(`[payload-mcp] Error updating config: ${errorMessage}`)
return {
content: [
{
type: 'text' as const,
text: `Error updating config: ${errorMessage}`,
},
],
}
}
}
server.tool(
'updateConfig',
toolSchemas.updateConfig.description,
toolSchemas.updateConfig.parameters.shape,
(args) => {
return tool(args)
},
)
}

View File

@@ -0,0 +1,420 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import { validatePayloadFile } from '../../helpers/fileValidation.js'
import { toolSchemas } from '../schemas.js'
const createOrUpdateJobFile = (
req: PayloadRequest,
verboseLogs: boolean,
jobsDir: string,
jobName: string,
jobType: 'task' | 'workflow',
jobSlug: string,
camelCaseJobSlug: string,
) => {
const payload = req.payload
const jobFilePath = join(jobsDir, `${jobName}.ts`)
const importName = `${camelCaseJobSlug}${jobType === 'task' ? 'Task' : 'Workflow'}`
const importPath = `./${jobType === 'task' ? 'tasks' : 'workflows'}/${camelCaseJobSlug}`
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Processing job file: ${jobFilePath}`)
}
if (existsSync(jobFilePath)) {
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Updating existing job file: ${jobFilePath}`)
}
// Update existing job file
let content = readFileSync(jobFilePath, 'utf8')
// Add import if not already present
const importStatement = `import { ${importName} } from '${importPath}'`
if (!content.includes(importStatement)) {
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Adding import: ${importStatement}`)
}
// Find the last import statement and add after it
const importRegex = /import\s+(?:\S.*)?from\s+['"].*['"];?\s*\n/g
let lastImportMatch
let match
while ((match = importRegex.exec(content)) !== null) {
lastImportMatch = match
}
if (lastImportMatch) {
const insertIndex = lastImportMatch.index + lastImportMatch[0].length
content =
content.slice(0, insertIndex) + importStatement + '\n' + content.slice(insertIndex)
} else {
// No imports found, add at the beginning
content = importStatement + '\n\n' + content
}
}
// Add to the appropriate array
const arrayName = jobType === 'task' ? 'tasks' : 'workflows'
const arrayRegex = new RegExp(`(${arrayName}:\\s*\\[)([^\\]]*)(\\])`, 's')
const arrayMatch = content.match(arrayRegex)
if (arrayMatch && arrayMatch[2]) {
const existingItems = arrayMatch[2].trim()
const newItem = existingItems ? `${existingItems},\n ${importName}` : `\n ${importName}`
content = content.replace(arrayRegex, `$1${newItem}\n $3`)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Added ${importName} to ${arrayName} array`)
}
} else {
// Array doesn't exist, add it
const jobsConfigRegex = /(export\s+const\s.*JobsConfig\s*=\s*\{)([^}]*)(\})/s
const jobsConfigMatch = content.match(jobsConfigRegex)
if (jobsConfigMatch && jobsConfigMatch[2]) {
const existingConfig = jobsConfigMatch[2].trim()
const newConfig = existingConfig
? `${existingConfig},\n ${arrayName}: [\n ${importName}\n ]`
: `\n ${arrayName}: [\n ${importName}\n ]`
content = content.replace(jobsConfigRegex, `$1${newConfig}\n$3`)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Created new ${arrayName} array with ${importName}`)
}
}
}
writeFileSync(jobFilePath, content)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Successfully updated job file: ${jobFilePath}`)
}
} else {
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Creating new job file: ${jobFilePath}`)
}
// Create new job file
const camelCaseJobName = toCamelCase(jobName)
const jobFileContent = `import type { JobsConfig } from 'payload'
import { ${importName} } from '${importPath}'
export const ${camelCaseJobName}JobsConfig: JobsConfig = {
${jobType === 'task' ? 'tasks' : 'workflows'}: [
${importName}
]
}
`
writeFileSync(jobFilePath, jobFileContent)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Successfully created new job file: ${jobFilePath}`)
}
}
}
// Reusable function for creating jobs
export const createJob = async (
req: PayloadRequest,
verboseLogs: boolean,
jobsDir: string,
jobName: string,
jobType: 'task' | 'workflow',
jobSlug: string,
description: string,
inputSchema: any,
outputSchema: any,
jobData: Record<string, any>,
) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Creating ${jobType}: ${jobName}`)
}
try {
// Ensure jobs directory exists
if (!existsSync(jobsDir)) {
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Creating jobs directory: ${jobsDir}`)
}
mkdirSync(jobsDir, { recursive: true })
}
// Ensure subdirectories exist
const tasksDir = join(jobsDir, 'tasks')
const workflowsDir = join(jobsDir, 'workflows')
if (!existsSync(tasksDir)) {
mkdirSync(tasksDir, { recursive: true })
}
if (!existsSync(workflowsDir)) {
mkdirSync(workflowsDir, { recursive: true })
}
const camelCaseJobSlug = toCamelCase(jobSlug)
const targetDir = jobType === 'task' ? tasksDir : workflowsDir
const fileName = `${camelCaseJobSlug}.ts`
const filePath = join(targetDir, fileName)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Target file path: ${filePath}`)
}
// Security check: ensure we're working with the jobs directory
if (!filePath.startsWith(jobsDir)) {
payload.logger.error(`[payload-mcp] Invalid job path attempted: ${filePath}`)
return {
content: [
{
type: 'text' as const,
text: '❌ **Error**: Invalid job path',
},
],
}
}
// Check if file already exists
if (existsSync(filePath)) {
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Job file already exists: ${fileName}`)
}
return {
content: [
{
type: 'text' as const,
text: `❌ **Error**: Job file already exists: ${fileName}`,
},
],
}
}
// Generate job content based on type
let jobContent: string
if (jobType === 'task') {
jobContent = generateTaskContent(
jobName,
jobSlug,
description,
inputSchema,
outputSchema,
jobData,
)
} else {
jobContent = generateWorkflowContent(
jobName,
jobSlug,
description,
inputSchema,
outputSchema,
jobData,
)
}
// Write the job file
writeFileSync(filePath, jobContent, 'utf8')
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Successfully created job file: ${filePath}`)
}
// Update the main job file
createOrUpdateJobFile(req, verboseLogs, jobsDir, jobName, jobType, jobSlug, camelCaseJobSlug)
// Validate the generated file
const validationResult = await validatePayloadFile(fileName, jobType)
if (validationResult.error) {
return {
content: [
{
type: 'text' as const,
text: `❌ **Error**: Generated job has validation issues:\n\n${validationResult.error}`,
},
],
}
}
return {
content: [
{
type: 'text' as const,
text: `✅ **Job created successfully!**
**File**: \`${fileName}\`
**Type**: \`${jobType}\`
**Slug**: \`${jobSlug}\`
**Description**: ${description}
**Generated Job Code:**
\`\`\`typescript
${jobContent}
\`\`\``,
},
],
}
} catch (error) {
const errorMessage = (error as Error).message
payload.logger.error(`[payload-mcp] Error creating job: ${errorMessage}`)
return {
content: [
{
type: 'text' as const,
text: `❌ **Error creating job**: ${errorMessage}`,
},
],
}
}
}
// Helper function to generate task content
function generateTaskContent(
jobName: string,
jobSlug: string,
description: string,
inputSchema: any,
outputSchema: any,
jobData: Record<string, any>,
): string {
const camelCaseJobSlug = toCamelCase(jobSlug)
return `import type { Task } from 'payload'
export const ${camelCaseJobSlug}Task: Task = {
slug: '${jobSlug}',
description: '${description}',
inputSchema: ${JSON.stringify(inputSchema, null, 2)},
outputSchema: ${JSON.stringify(outputSchema, null, 2)},
handler: async (input, context) => {
// TODO: Implement your task logic here
// Access input data: input.fieldName
// Access context: context.payload, context.req, etc.
// Example implementation:
const result = {
message: 'Task executed successfully',
input,
timestamp: new Date().toISOString(),
}
return result
},
}
`
}
// Helper function to generate workflow content
function generateWorkflowContent(
jobName: string,
jobSlug: string,
description: string,
inputSchema: any,
outputSchema: any,
jobData: Record<string, any>,
): string {
const camelCaseJobSlug = toCamelCase(jobSlug)
return `import type { Workflow } from 'payload'
export const ${camelCaseJobSlug}Workflow: Workflow = {
slug: '${jobSlug}',
description: '${description}',
inputSchema: ${JSON.stringify(inputSchema, null, 2)},
outputSchema: ${JSON.stringify(outputSchema, null, 2)},
steps: [
// TODO: Define your workflow steps here
// Each step should be a function that returns a result
// Example:
// {
// name: 'step1',
// handler: async (input, context) => {
// // Step logic here
// return { result: 'step1 completed' }
// }
// }
],
}
`
}
// Helper function to convert to camel case
function toCamelCase(str: string): string {
return str
.replace(/[-_\s]+(.)?/g, (_, chr) => (chr ? chr.toUpperCase() : ''))
.replace(/^(.)/, (_, chr) => chr.toLowerCase())
}
export const createJobTool = (
server: McpServer,
req: PayloadRequest,
verboseLogs: boolean,
jobsDir: string,
) => {
const tool = async (
jobName: string,
jobType: 'task' | 'workflow',
jobSlug: string,
description: string,
inputSchema: any = {},
outputSchema: any = {},
jobData: Record<string, any> = {},
) => {
if (verboseLogs) {
req.payload.logger.info(
`[payload-mcp] Create Job Tool called with: ${jobName}, ${jobType}, ${jobSlug}`,
)
}
try {
const result = await createJob(
req,
verboseLogs,
jobsDir,
jobName,
jobType,
jobSlug,
description,
inputSchema,
outputSchema,
jobData,
)
if (verboseLogs) {
req.payload.logger.info(`[payload-mcp] Create Job Tool completed successfully`)
}
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
req.payload.logger.error(`[payload-mcp] Error in Create Job Tool: ${errorMessage}`)
return {
content: [
{
type: 'text' as const,
text: `❌ **Error in Create Job Tool**: ${errorMessage}`,
},
],
}
}
}
server.tool(
'createJob',
'Creates a new Payload job (task or workflow) with specified configuration',
toolSchemas.createJob.parameters.shape,
async (args) => {
return tool(
args.jobName,
args.jobType,
args.jobSlug,
args.description,
args.inputSchema,
args.outputSchema,
args.jobData,
)
},
)
}

View File

@@ -0,0 +1,189 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import { toolSchemas } from '../schemas.js'
// Reusable function for running jobs
export const runJob = async (
req: PayloadRequest,
verboseLogs: boolean,
jobSlug: string,
input: Record<string, any>,
queue?: string,
priority?: number,
delay?: number,
) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Running job: ${jobSlug}`)
}
try {
// Actually run the job using Payload's job queue
const jobQueueOptions: Record<string, unknown> = {
input,
task: jobSlug,
}
if (queue && queue !== 'default') {
jobQueueOptions.queue = queue
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Using custom queue: ${queue}`)
}
}
if (priority && priority > 0) {
jobQueueOptions.priority = priority
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Setting job priority: ${priority}`)
}
}
if (delay && delay > 0) {
jobQueueOptions.waitUntil = new Date(Date.now() + delay)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Setting job delay: ${delay}ms`)
}
}
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Queuing job with options: ${JSON.stringify(jobQueueOptions)}`,
)
}
const job = await payload.jobs.queue(
jobQueueOptions as Parameters<typeof payload.jobs.queue>[0],
)
const jobId = (job as { id?: string })?.id || 'unknown'
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Job created successfully: ${jobId}`)
}
return {
content: [
{
type: 'text' as const,
text: `# Job Queued Successfully: ${jobSlug}
## Job Details
- **Job ID**: ${jobId}
- **Job Slug**: ${jobSlug}
- **Queue**: ${queue || 'default'}
- **Priority**: ${priority || 'default'}
- **Delay**: ${delay ? `${delay}ms` : 'none'}
- **Status**: Queued and Running
## Input Data
\`\`\`json
${JSON.stringify(input, null, 2)}
\`\`\`
## Job Status
The job has been successfully queued and will be processed according to the queue settings.
## Monitoring the Job
You can monitor the job status using:
\`\`\`typescript
// Check job status
const jobStatus = await payload.jobs.status('${jobId}')
console.log('Job status:', jobStatus)
// Wait for completion
const result = await payload.jobs.wait('${jobId}')
console.log('Job result:', result)
\`\`\`
✅ Job successfully queued with ID: ${jobId}`,
},
],
}
} catch (error) {
const errorMsg = (error as Error).message
payload.logger.error(`[payload-mcp] Error running job "${jobSlug}": ${errorMsg}`)
return {
content: [
{
type: 'text' as const,
text: `❌ Error running job "${jobSlug}": ${errorMsg}
## Common Issues:
1. **Job not found**: The job "${jobSlug}" may not be registered in your Payload configuration
2. **Invalid input format**: Ensure the input matches the job's input schema
3. **Queue not configured**: The queue "${queue || 'default'}" may not be properly set up
4. **Permission issues**: Ensure proper access rights for job execution
5. **Job handler error**: The job implementation may have errors
## Input Data Provided:
\`\`\`json
${JSON.stringify(input, null, 2)}
\`\`\`
## Next Steps:
1. **Verify job exists**: Check that the job "${jobSlug}" is properly registered
2. **Check input format**: Ensure the input data matches the expected schema
3. **Review job configuration**: Verify the job is properly configured in your Payload setup
4. **Check permissions**: Ensure you have the necessary permissions to run jobs
5. **Review error logs**: Check the server logs for more detailed error information
## Troubleshooting:
- **Job not found**: Verify the job slug and check your jobs configuration
- **Schema mismatch**: Ensure input data matches the job's input schema
- **Queue issues**: Check that the specified queue is properly configured
- **Permission errors**: Verify user permissions for job execution`,
},
],
}
}
}
export const runJobTool = (server: McpServer, req: PayloadRequest, verboseLogs: boolean) => {
const tool = async (
jobSlug: string,
input: Record<string, any>,
queue?: string,
priority?: number,
delay?: number,
) => {
if (verboseLogs) {
req.payload.logger.info(`[payload-mcp] Run Job Tool called with: ${jobSlug}`)
}
try {
const result = await runJob(req, verboseLogs, jobSlug, input, queue, priority, delay)
if (verboseLogs) {
req.payload.logger.info(`[payload-mcp] Run Job Tool completed successfully`)
}
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
req.payload.logger.error(`[payload-mcp] Error in Run Job Tool: ${errorMessage}`)
return {
content: [
{
type: 'text' as const,
text: `❌ **Error in Run Job Tool**: ${errorMessage}`,
},
],
}
}
}
server.tool(
'runJob',
'Runs a Payload job with specified input data and queue options',
toolSchemas.runJob.parameters.shape,
async (args) => {
const { delay, input, jobSlug, priority, queue } = args
return await tool(jobSlug, input, queue, priority, delay)
},
)
}

View File

@@ -0,0 +1,319 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import { existsSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import type { JobConfigUpdate, SchemaField, TaskSequenceItem } from '../../../types.js'
import { validatePayloadFile } from '../../helpers/fileValidation.js'
import { toolSchemas } from '../schemas.js'
// Reusable function for updating jobs
export const updateJob = async (
req: PayloadRequest,
verboseLogs: boolean,
jobsDir: string,
jobSlug: string,
updateType: string,
inputSchema?: SchemaField[],
outputSchema?: SchemaField[],
taskSequence?: TaskSequenceItem[],
configUpdate?: JobConfigUpdate,
handlerCode?: string,
) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Updating job: ${jobSlug} (${updateType})`)
}
try {
const camelCaseJobSlug = toCamelCase(jobSlug)
// Find the job file - check both tasks and workflows
let filePath: null | string = null
let jobType: 'task' | 'workflow' | null = null
const taskPath = join(jobsDir, 'tasks', `${camelCaseJobSlug}.ts`)
const workflowPath = join(jobsDir, 'workflows', `${camelCaseJobSlug}.ts`)
if (existsSync(taskPath)) {
filePath = taskPath
jobType = 'task'
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Found task file: ${taskPath}`)
}
} else if (existsSync(workflowPath)) {
filePath = workflowPath
jobType = 'workflow'
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Found workflow file: ${workflowPath}`)
}
} else {
throw new Error(`No task or workflow file found for job slug: ${jobSlug}`)
}
// Read the current file content
let content = readFileSync(filePath, 'utf8')
const originalContent = content
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Applying update type: ${updateType}`)
}
// Apply updates based on type
switch (updateType) {
case 'change_config':
if (!configUpdate) {
throw new Error('config must be provided for change_config')
}
content = updateConfig(content, jobSlug, configUpdate)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Configuration updated successfully`)
}
break
case 'modify_schema':
if (!inputSchema && !outputSchema) {
throw new Error('Either inputSchema or outputSchema must be provided for modify_schema')
}
content = updateSchema(content, camelCaseJobSlug, inputSchema, outputSchema)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Schema updated successfully`)
}
break
case 'replace_handler':
if (!handlerCode) {
throw new Error('handlerCode must be provided for replace_handler')
}
content = updateHandler(content, handlerCode, jobType)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Handler code replaced successfully`)
}
break
case 'update_tasks':
if (!taskSequence) {
throw new Error('taskSequence must be provided for update_tasks')
}
if (jobType !== 'workflow') {
throw new Error('update_tasks is only supported for workflow jobs')
}
content = updateWorkflowTasks(content, taskSequence)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Workflow tasks updated successfully`)
}
break
}
// Only write if content changed
if (content !== originalContent) {
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Writing updated content to file`)
}
// Write the updated content
writeFileSync(filePath, content)
// Validate the updated file
const fileName = `${camelCaseJobSlug}.ts`
const validationType = jobType === 'task' ? 'task' : 'workflow'
try {
const validationResult = await validatePayloadFile(fileName, validationType)
if (!validationResult.success) {
if (verboseLogs) {
payload.logger.warn(`[payload-mcp] Validation warning: ${validationResult.error}`)
}
return {
content: [
{
type: 'text' as const,
text: `⚠️ **Warning**: Job updated but validation failed:\n\n${validationResult.error}\n\nPlease review the generated code for any syntax errors.`,
},
],
}
}
if (verboseLogs) {
payload.logger.info(`[payload-mcp] File validation successful`)
}
} catch (validationError) {
if (verboseLogs) {
payload.logger.warn(`[payload-mcp] Validation error: ${validationError}`)
}
return {
content: [
{
type: 'text' as const,
text: `⚠️ **Warning**: Job updated but validation could not be completed:\n\n${validationError}\n\nPlease review the generated code manually.`,
},
],
}
}
return {
content: [
{
type: 'text' as const,
text: `✅ **Job updated successfully!**\n\n**Job**: \`${jobSlug}\`\n**Type**: \`${jobType}\`\n**Update**: \`${updateType}\`\n**File**: \`${fileName}\`\n\n**Next steps**:\n1. Restart your development server to load the updated job\n2. Test the updated functionality\n3. Verify the changes meet your requirements`,
},
],
}
} else {
if (verboseLogs) {
payload.logger.info(`[payload-mcp] No changes detected, file not modified`)
}
return {
content: [
{
type: 'text' as const,
text: ` **No changes made**: The job file was not modified as no changes were detected.\n\n**Job**: \`${jobSlug}\`\n**Type**: \`${jobType}\`\n**Update**: \`${updateType}\``,
},
],
}
}
} catch (error) {
const errorMessage = (error as Error).message
payload.logger.error(`[payload-mcp] Error updating job: ${errorMessage}`)
return {
content: [
{
type: 'text' as const,
text: `❌ **Error updating job**: ${errorMessage}`,
},
],
}
}
}
// Helper function to convert to camel case
function toCamelCase(str: string): string {
return str
.replace(/[-_\s]+(.)?/g, (_, chr) => (chr ? chr.toUpperCase() : ''))
.replace(/^(.)/, (_, chr) => chr.toLowerCase())
}
// Helper functions for different update types
function updateSchema(
content: string,
camelCaseJobSlug: string,
inputSchema?: SchemaField[],
outputSchema?: SchemaField[],
): string {
// TODO: Implementation for schema updates
// This would modify the inputSchema and outputSchema in the job file
return content
}
function updateWorkflowTasks(content: string, taskSequence: TaskSequenceItem[]): string {
// TODO: Implementation for updating workflow tasks
// This would modify the steps array in the workflow
return content
}
function updateConfig(content: string, jobSlug: string, configUpdate: JobConfigUpdate): string {
// TODO: Implementation for updating job configuration
// This would modify various config properties
return content
}
function updateHandler(content: string, handlerCode: string, jobType: 'task' | 'workflow'): string {
// TODO: Implementation for replacing handler code
// This would replace the handler function in the job file
return content
}
export const updateJobTool = (
server: McpServer,
req: PayloadRequest,
verboseLogs: boolean,
jobsDir: string,
) => {
const tool = async (
jobSlug: string,
updateType: string,
inputSchema?: SchemaField[],
outputSchema?: SchemaField[],
taskSequence?: TaskSequenceItem[],
configUpdate?: JobConfigUpdate,
handlerCode?: string,
) => {
if (verboseLogs) {
req.payload.logger.info(
`[payload-mcp] Update Job Tool called with: ${jobSlug}, ${updateType}`,
)
}
try {
const result = await updateJob(
req,
verboseLogs,
jobsDir,
jobSlug,
updateType,
inputSchema,
outputSchema,
taskSequence,
configUpdate,
handlerCode,
)
if (verboseLogs) {
req.payload.logger.info(`[payload-mcp] Update Job Tool completed successfully`)
}
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
req.payload.logger.error(`[payload-mcp] Error in Update Job Tool: ${errorMessage}`)
return {
content: [
{
type: 'text' as const,
text: `❌ **Error in Update Job Tool**: ${errorMessage}`,
},
],
}
}
}
server.tool(
'updateJob',
'Updates an existing Payload job with new configuration, schema, or handler code',
toolSchemas.updateJob.parameters.shape,
async (args) => {
const {
configUpdate,
handlerCode,
inputSchema,
jobSlug,
outputSchema,
taskSequence,
updateType,
} = args
return await tool(
jobSlug,
updateType,
inputSchema as unknown as SchemaField[],
outputSchema as unknown as SchemaField[],
taskSequence,
configUpdate,
handlerCode,
)
},
)
}

View File

@@ -0,0 +1,95 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import type { PluginMCPServerConfig } from '../../../types.js'
import { toCamelCase } from '../../../utils/camelCase.js'
import { toolSchemas } from '../schemas.js'
export const createResourceTool = (
server: McpServer,
req: PayloadRequest,
verboseLogs: boolean,
collectionSlug: string,
collections: PluginMCPServerConfig['collections'],
) => {
const tool = async (data: string, draft: boolean = false) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Creating resource in collection: ${collectionSlug}, draft: ${draft}`,
)
}
try {
// Parse the data JSON
let parsedData: Record<string, unknown>
try {
parsedData = JSON.parse(data)
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Parsed data for ${collectionSlug}: ${JSON.stringify(parsedData)}`,
)
}
} catch (_parseError) {
payload.logger.error(`[payload-mcp] Invalid JSON data provided: ${data}`)
return {
content: [{ type: 'text' as const, text: 'Error: Invalid JSON data provided' }],
}
}
// Create the resource
const result = await payload.create({
collection: collectionSlug,
data: collections?.[collectionSlug]?.override?.(parsedData, req) || parsedData,
draft,
overrideAccess: true,
})
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Successfully created resource in ${collectionSlug} with ID: ${result.id}`,
)
}
return {
content: [
{
type: 'text' as const,
text: `Resource created successfully in collection "${collectionSlug}"!
Created resource:
\`\`\`json
${JSON.stringify(result, null, 2)}
\`\`\``,
},
],
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
payload.logger.error(
`[payload-mcp] Error creating resource in ${collectionSlug}: ${errorMessage}`,
)
return {
content: [
{
type: 'text' as const,
text: `Error creating resource in collection "${collectionSlug}": ${errorMessage}`,
},
],
}
}
}
if (collections?.[collectionSlug]?.enabled) {
server.tool(
`create${collectionSlug.charAt(0).toUpperCase() + toCamelCase(collectionSlug).slice(1)}Document`,
`${toolSchemas.createResource.description.trim()}\n\n${collections?.[collectionSlug]?.description}`,
toolSchemas.createResource.parameters.shape,
async ({ data, draft }) => {
return await tool(data, draft)
},
)
}
}

View File

@@ -0,0 +1,165 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import type { PluginMCPServerConfig } from '../../../types.js'
import { toCamelCase } from '../../../utils/camelCase.js'
import { toolSchemas } from '../schemas.js'
export const deleteResourceTool = (
server: McpServer,
req: PayloadRequest,
verboseLogs: boolean,
collectionSlug: string,
collections: PluginMCPServerConfig['collections'],
) => {
const tool = async (
id?: string,
where?: string,
depth: number = 0,
overrideAccess: boolean = true,
) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Deleting resource from collection: ${collectionSlug}${id ? ` with ID: ${id}` : ' with where clause'}`,
)
}
try {
// Validate that either id or where is provided
if (!id && !where) {
payload.logger.error('[payload-mcp] Either id or where clause must be provided')
return {
content: [
{ type: 'text' as const, text: 'Error: Either id or where clause must be provided' },
],
}
}
// Parse where clause if provided
let whereClause = {}
if (where) {
try {
whereClause = JSON.parse(where)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Using where clause: ${where}`)
}
} catch (_parseError) {
payload.logger.warn(`[payload-mcp] Invalid where clause JSON: ${where}`)
return {
content: [{ type: 'text' as const, text: 'Error: Invalid JSON in where clause' }],
}
}
}
// Build delete options
const deleteOptions: Record<string, unknown> = {
collection: collectionSlug,
depth,
overrideAccess,
}
// Delete by ID or where clause
if (id) {
deleteOptions.id = id
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Deleting single document with ID: ${id}`)
}
} else {
deleteOptions.where = whereClause
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Deleting multiple documents with where clause`)
}
}
const result = await payload.delete(deleteOptions as Parameters<typeof payload.delete>[0])
// Handle different result types
if (id) {
// Single document deletion
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Successfully deleted document with ID: ${id}`)
}
return {
content: [
{
type: 'text' as const,
text: `Document deleted successfully from collection "${collectionSlug}"!
Deleted document:
\`\`\`json
${JSON.stringify(result, null, 2)}
\`\`\``,
},
],
}
} else {
// Multiple documents deletion
const bulkResult = result as { docs?: unknown[]; errors?: unknown[] }
const docs = bulkResult.docs || []
const errors = bulkResult.errors || []
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Successfully deleted ${docs.length} documents, ${errors.length} errors`,
)
}
let responseText = `Multiple documents deleted from collection "${collectionSlug}"!
Deleted: ${docs.length} documents
Errors: ${errors.length}
---`
if (docs.length > 0) {
responseText += `\n\nDeleted documents:
\`\`\`json
${JSON.stringify(docs, null, 2)}
\`\`\``
}
if (errors.length > 0) {
responseText += `\n\nErrors:
\`\`\`json
${JSON.stringify(errors, null, 2)}
\`\`\``
}
return {
content: [
{
type: 'text' as const,
text: responseText,
},
],
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
payload.logger.error(
`[payload-mcp] Error deleting resource from ${collectionSlug}: ${errorMessage}`,
)
return {
content: [
{
type: 'text' as const,
text: `Error deleting resource from collection "${collectionSlug}": ${errorMessage}`,
},
],
}
}
}
if (collections?.[collectionSlug]?.enabled) {
server.tool(
`delete${collectionSlug.charAt(0).toUpperCase() + toCamelCase(collectionSlug).slice(1)}Document`,
`${toolSchemas.deleteResource.description.trim()}\n\n${collections?.[collectionSlug]?.description}`,
toolSchemas.deleteResource.parameters.shape,
async ({ id, depth, overrideAccess, where }) => {
return await tool(id, where, depth, overrideAccess)
},
)
}
}

View File

@@ -0,0 +1,150 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import type { PluginMCPServerConfig } from '../../../types.js'
import { toCamelCase } from '../../../utils/camelCase.js'
import { toolSchemas } from '../schemas.js'
export const findResourceTool = (
server: McpServer,
req: PayloadRequest,
verboseLogs: boolean,
collectionSlug: string,
collections: PluginMCPServerConfig['collections'],
) => {
const tool = async (
id?: string,
limit: number = 10,
page: number = 1,
sort?: string,
where?: string,
) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Reading resource from collection: ${collectionSlug}${id ? ` with ID: ${id}` : ''}, limit: ${limit}, page: ${page}`,
)
}
try {
// Parse where clause if provided
let whereClause = {}
if (where) {
try {
whereClause = JSON.parse(where)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Using where clause: ${where}`)
}
} catch (_parseError) {
payload.logger.warn(`[payload-mcp] Invalid where clause JSON: ${where}`)
return {
content: [{ type: 'text' as const, text: 'Error: Invalid JSON in where clause' }],
}
}
}
// If ID is provided, use findByID
if (id) {
try {
const doc = await payload.findByID({
id,
collection: collectionSlug,
})
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Found document with ID: ${id}`)
}
return {
content: [
{
type: 'text' as const,
text: `Resource from collection "${collectionSlug}":
${JSON.stringify(doc, null, 2)}`,
},
],
}
} catch (_findError) {
payload.logger.warn(
`[payload-mcp] Document not found with ID: ${id} in collection: ${collectionSlug}`,
)
return {
content: [
{
type: 'text' as const,
text: `Error: Document with ID "${id}" not found in collection "${collectionSlug}"`,
},
],
}
}
}
// Otherwise, use find to get multiple documents
const findOptions: Parameters<typeof payload.find>[0] = {
collection: collectionSlug,
limit,
page,
}
if (sort) {
findOptions.sort = sort
}
if (Object.keys(whereClause).length > 0) {
findOptions.where = whereClause
}
const result = await payload.find(findOptions)
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Found ${result.docs.length} documents in collection: ${collectionSlug}`,
)
}
let responseText = `Collection: "${collectionSlug}"
Total: ${result.totalDocs} documents
Page: ${result.page} of ${result.totalPages}
`
for (const doc of result.docs) {
responseText += `\n\`\`\`json\n${JSON.stringify(doc, null, 2)}\n\`\`\``
}
return {
content: [
{
type: 'text' as const,
text: responseText,
},
],
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
payload.logger.error(
`[payload-mcp] Error reading resources from collection ${collectionSlug}: ${errorMessage}`,
)
return {
content: [
{
type: 'text' as const,
text: `❌ **Error reading resources from collection "${collectionSlug}":** ${errorMessage}`,
},
],
}
}
}
if (collections?.[collectionSlug]?.enabled) {
server.tool(
`find${collectionSlug.charAt(0).toUpperCase() + toCamelCase(collectionSlug).slice(1)}Document`,
`${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)
},
)
}
}

View File

@@ -0,0 +1,211 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PayloadRequest } from 'payload'
import type { PluginMCPServerConfig } from '../../../types.js'
import { toCamelCase } from '../../../utils/camelCase.js'
import { toolSchemas } from '../schemas.js'
export const updateResourceTool = (
server: McpServer,
req: PayloadRequest,
verboseLogs: boolean,
collectionSlug: string,
collections: PluginMCPServerConfig['collections'],
) => {
const tool = async (
data: string,
id?: string,
where?: string,
draft: boolean = false,
depth: number = 0,
overrideLock: boolean = true,
filePath?: string,
overwriteExistingFiles: boolean = false,
) => {
const payload = req.payload
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Updating resource in collection: ${collectionSlug}${id ? ` with ID: ${id}` : ' with where clause'}, draft: ${draft}`,
)
}
try {
// Parse the data JSON
let parsedData: Record<string, unknown>
try {
parsedData = JSON.parse(data)
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Parsed data for ${collectionSlug}: ${JSON.stringify(parsedData)}`,
)
}
} catch (_parseError) {
payload.logger.error(`[payload-mcp] Invalid JSON data provided: ${data}`)
return {
content: [{ type: 'text' as const, text: 'Error: Invalid JSON data provided' }],
}
}
// Validate that either id or where is provided
if (!id && !where) {
payload.logger.error('[payload-mcp] Either id or where clause must be provided')
return {
content: [
{ type: 'text' as const, text: 'Error: Either id or where clause must be provided' },
],
}
}
// Parse where clause if provided
let whereClause = {}
if (where) {
try {
whereClause = JSON.parse(where)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Using where clause: ${where}`)
}
} catch (_parseError) {
payload.logger.error(`[payload-mcp] Invalid where clause JSON: ${where}`)
return {
content: [{ type: 'text' as const, text: 'Error: Invalid JSON in where clause' }],
}
}
}
// Update by ID or where clause
if (id) {
// Single document update
const updateOptions = {
id,
collection: collectionSlug,
data: parsedData,
depth,
draft,
overrideAccess: true,
overrideLock,
...(filePath && { filePath }),
...(overwriteExistingFiles && { overwriteExistingFiles }),
}
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Updating single document with ID: ${id}`)
}
const result = await payload.update({
...updateOptions,
data: collections?.[collectionSlug]?.override?.(parsedData, req) || parsedData,
} as any)
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Successfully updated document with ID: ${id}`)
}
return {
content: [
{
type: 'text' as const,
text: `Document updated successfully in collection "${collectionSlug}"!
Updated document:
\`\`\`json
${JSON.stringify(result, null, 2)}
\`\`\``,
},
],
}
} else {
// Multiple documents update
const updateOptions = {
collection: collectionSlug,
data: parsedData,
depth,
draft,
overrideAccess: true,
overrideLock,
where: whereClause,
...(filePath && { filePath }),
...(overwriteExistingFiles && { overwriteExistingFiles }),
}
if (verboseLogs) {
payload.logger.info(`[payload-mcp] Updating multiple documents with where clause`)
}
const result = await payload.update({
...updateOptions,
data: collections?.[collectionSlug]?.override?.(parsedData, req) || parsedData,
} as any)
const bulkResult = result as { docs?: unknown[]; errors?: unknown[] }
const docs = bulkResult.docs || []
const errors = bulkResult.errors || []
if (verboseLogs) {
payload.logger.info(
`[payload-mcp] Successfully updated ${docs.length} documents, ${errors.length} errors`,
)
}
let responseText = `Multiple documents updated in collection "${collectionSlug}"!
Updated: ${docs.length} documents
Errors: ${errors.length}
---`
if (docs.length > 0) {
responseText += `\n\nUpdated documents:
\`\`\`json
${JSON.stringify(docs, null, 2)}
\`\`\``
}
if (errors.length > 0) {
responseText += `\n\nErrors:
\`\`\`json
${JSON.stringify(errors, null, 2)}
\`\`\``
}
return {
content: [
{
type: 'text' as const,
text: responseText,
},
],
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
payload.logger.error(
`[payload-mcp] Error updating resource in ${collectionSlug}: ${errorMessage}`,
)
return {
content: [
{
type: 'text' as const,
text: `Error updating resource in collection "${collectionSlug}": ${errorMessage}`,
},
],
}
}
}
if (collections?.[collectionSlug]?.enabled) {
server.tool(
`update${collectionSlug.charAt(0).toUpperCase() + toCamelCase(collectionSlug).slice(1)}Document`,
`${toolSchemas.updateResource.description.trim()}\n\n${collections?.[collectionSlug]?.description}`,
toolSchemas.updateResource.parameters.shape,
async ({ id, data, depth, draft, filePath, overrideLock, overwriteExistingFiles, where }) => {
return await tool(
data,
id,
where,
draft,
depth,
overrideLock,
filePath,
overwriteExistingFiles,
)
},
)
}
}

View File

@@ -0,0 +1,378 @@
import { z } from 'zod'
export const toolSchemas = {
findResources: {
description: 'Find documents in a Payload collection using Find or FindByID.',
parameters: z.object({
id: z
.string()
.optional()
.describe(
'Optional: specific document ID to retrieve. If not provided, returns all documents',
),
limit: z
.number()
.int()
.min(1, 'Limit must be at least 1')
.max(100, 'Limit cannot exceed 100')
.optional()
.default(10)
.describe('Maximum number of documents to return (default: 10, max: 100)'),
page: z
.number()
.int()
.min(1, 'Page must be at least 1')
.optional()
.default(1)
.describe('Page number for pagination (default: 1)'),
sort: z
.string()
.optional()
.describe('Field to sort by (e.g., "createdAt", "-updatedAt" for descending)'),
where: z
.string()
.optional()
.describe(
'Optional JSON string for where clause filtering (e.g., \'{"title": {"contains": "test"}}\')',
),
}),
},
createResource: {
description: 'Create a document in a Payload collection.',
parameters: z.object({
data: z.string().describe('JSON string containing the data for the new document'),
draft: z
.boolean()
.optional()
.default(false)
.describe('Whether to create the document as a draft'),
}),
},
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'),
data: z.string().describe('JSON string containing the data to update'),
depth: z
.number()
.int()
.min(0)
.max(10)
.optional()
.default(0)
.describe('Depth of population for relationships'),
draft: z.boolean().optional().default(false).describe('Whether to update as a draft'),
filePath: z.string().optional().describe('Optional: absolute file path for file uploads'),
overrideLock: z
.boolean()
.optional()
.default(true)
.describe('Whether to override document locks'),
overwriteExistingFiles: z
.boolean()
.optional()
.default(false)
.describe('Whether to overwrite existing files'),
where: z
.string()
.optional()
.describe('Optional: JSON string for where clause to update multiple documents'),
}),
},
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'),
depth: z
.number()
.int()
.min(0)
.max(10)
.optional()
.default(0)
.describe('Depth of population for relationships in response'),
overrideAccess: z
.boolean()
.optional()
.default(true)
.describe('Whether to override access controls'),
where: z
.string()
.optional()
.describe('Optional: JSON string for where clause to delete multiple documents'),
}),
},
// Experimental Below This Line
createCollection: {
description: 'Creates a new Payload collection with specified fields and configuration.',
parameters: z.object({
collectionDescription: z
.string()
.optional()
.describe('Optional description for the collection'),
collectionName: z.string().describe('The name of the collection to create'),
fields: z.array(z.any()).describe('Array of field definitions for the collection'),
hasUpload: z
.boolean()
.optional()
.describe('Whether the collection should have upload capabilities'),
}),
},
findCollections: {
description: 'Finds and lists Payload collections with optional content and document counts.',
parameters: z.object({
collectionName: z
.string()
.optional()
.describe('Optional: specific collection name to retrieve'),
includeContent: z
.boolean()
.optional()
.default(false)
.describe('Whether to include collection file content'),
includeCount: z
.boolean()
.optional()
.default(false)
.describe('Whether to include document counts for each collection'),
}),
},
updateCollection: {
description:
'Updates an existing Payload collection with new fields, modifications, or configuration changes.',
parameters: z.object({
collectionName: z.string().describe('The name of the collection to update'),
configUpdates: z.any().optional().describe('Configuration updates (for update_config type)'),
fieldModifications: z
.array(z.any())
.optional()
.describe('Field modifications (for modify_field type)'),
fieldNamesToRemove: z
.array(z.string())
.optional()
.describe('Field names to remove (for remove_field type)'),
newContent: z
.string()
.optional()
.describe('New content to replace entire collection (for replace_content type)'),
newFields: z.array(z.any()).optional().describe('New fields to add (for add_field type)'),
updateType: z
.enum(['add_field', 'remove_field', 'modify_field', 'update_config', 'replace_content'])
.describe('Type of update to perform'),
}),
},
deleteCollection: {
description: 'Deletes a Payload collection and optionally updates the configuration.',
parameters: z.object({
collectionName: z.string().describe('The name of the collection to delete'),
confirmDeletion: z.boolean().describe('Confirmation flag to prevent accidental deletion'),
updateConfig: z
.boolean()
.optional()
.default(false)
.describe('Whether to update payload.config.ts to remove collection reference'),
}),
},
findConfig: {
description: 'Reads and displays the current Payload configuration file.',
parameters: z.object({
includeMetadata: z
.boolean()
.optional()
.default(false)
.describe('Whether to include file metadata (size, modified date, etc.)'),
}),
},
updateConfig: {
description: 'Updates the Payload configuration file with various modifications.',
parameters: z.object({
adminConfig: z
.any()
.optional()
.describe('Admin configuration updates (for update_admin type)'),
collectionName: z
.string()
.optional()
.describe('Collection name (required for add_collection and remove_collection)'),
databaseConfig: z
.any()
.optional()
.describe('Database configuration updates (for update_database type)'),
generalConfig: z.any().optional().describe('General configuration updates'),
newContent: z
.string()
.optional()
.describe('New configuration content (for replace_content type)'),
pluginUpdates: z
.any()
.optional()
.describe('Plugin configuration updates (for update_plugins type)'),
updateType: z
.enum([
'add_collection',
'remove_collection',
'update_admin',
'update_database',
'update_plugins',
'replace_content',
])
.describe('Type of configuration update to perform'),
}),
},
auth: {
description: 'Checks authentication status for the current user.',
parameters: z.object({
headers: z
.string()
.optional()
.describe(
'Optional JSON string containing custom headers to send with the authentication request',
),
}),
},
login: {
description: 'Authenticates a user with email and password.',
parameters: z.object({
collection: z.string().describe('The collection containing the user (e.g., "users")'),
depth: z
.number()
.int()
.min(0)
.max(10)
.optional()
.default(0)
.describe('Depth of population for relationships'),
email: z.string().email().describe('The user email address'),
overrideAccess: z
.boolean()
.optional()
.default(false)
.describe('Whether to override access controls'),
password: z.string().describe('The user password'),
showHiddenFields: z
.boolean()
.optional()
.default(false)
.describe('Whether to show hidden fields in the response'),
}),
},
verify: {
description: 'Verifies a user email with a verification token.',
parameters: z.object({
collection: z.string().describe('The collection containing the user (e.g., "users")'),
token: z.string().describe('The verification token sent to the user email'),
}),
},
resetPassword: {
description: 'Resets a user password with a reset token.',
parameters: z.object({
collection: z.string().describe('The collection containing the user (e.g., "users")'),
password: z.string().describe('The new password for the user'),
token: z.string().describe('The password reset token sent to the user email'),
}),
},
forgotPassword: {
description: 'Sends a password reset email to a user.',
parameters: z.object({
collection: z.string().describe('The collection containing the user (e.g., "users")'),
disableEmail: z
.boolean()
.optional()
.default(false)
.describe('Whether to disable sending the email (for testing)'),
email: z.string().email().describe('The user email address'),
}),
},
unlock: {
description: 'Unlocks a user account that has been locked due to failed login attempts.',
parameters: z.object({
collection: z.string().describe('The collection containing the user (e.g., "users")'),
email: z.string().email().describe('The user email address'),
}),
},
createJob: {
description: 'Creates a new Payload job (task or workflow) with specified configuration.',
parameters: z.object({
description: z.string().describe('Description of what the job does'),
inputSchema: z.record(z.any()).optional().default({}).describe('Input schema for the job'),
jobData: z
.record(z.any())
.optional()
.default({})
.describe('Additional job configuration data'),
jobName: z
.string()
.min(1, 'Job name cannot be empty')
.regex(/^[a-z][\w-]*$/i, 'Job name must be alphanumeric and can contain underscores/dashes')
.describe('The name of the job to create'),
jobSlug: z
.string()
.min(1, 'Job slug cannot be empty')
.regex(/^[a-z][a-z0-9-]*$/, 'Job slug must be kebab-case')
.describe('The slug for the job (kebab-case format)'),
jobType: z
.enum(['task', 'workflow'])
.describe('Whether to create a task (individual unit) or workflow (orchestrates tasks)'),
outputSchema: z.record(z.any()).optional().default({}).describe('Output schema for the job'),
}),
},
updateJob: {
description: 'Updates an existing Payload job with new configuration, schema, or handler code.',
parameters: z.object({
configUpdate: z.record(z.any()).optional().describe('New configuration for the job'),
handlerCode: z
.string()
.optional()
.describe('New handler code to replace the existing handler'),
inputSchema: z.record(z.any()).optional().describe('New input schema for the job'),
jobSlug: z.string().describe('The slug of the job to update'),
outputSchema: z.record(z.any()).optional().describe('New output schema for the job'),
taskSequence: z.array(z.any()).optional().describe('New task sequence for workflows'),
updateType: z
.enum(['modify_schema', 'update_tasks', 'change_config', 'replace_handler'])
.describe('Type of update to perform on the job'),
}),
},
runJob: {
description: 'Runs a Payload job with specified input data and queue options.',
parameters: z.object({
delay: z
.number()
.int()
.min(0)
.optional()
.describe('Delay in milliseconds before job execution'),
input: z.record(z.any()).describe('Input data for the job execution'),
jobSlug: z.string().describe('The slug of the job to run'),
priority: z
.number()
.int()
.min(1)
.max(10)
.optional()
.describe('Job priority (1-10, higher is more important)'),
queue: z
.string()
.optional()
.describe('Queue name to use for job execution (default: "default")'),
}),
},
}

View File

@@ -0,0 +1,215 @@
import type { Collection, CollectionConfig, CollectionSlug, PayloadRequest } from 'payload'
import type { z } from 'zod'
export type PluginMCPServerConfig = {
/**
* Experimental features
* **These features are for experimental purposes -- They are Disabled in Production by Default**
*/
_experimental?: {
tools: {
auth?: {
enabled: boolean
}
collections?: {
collectionsDirPath: string
enabled: boolean
}
config?: {
configFilePath: string
enabled: boolean
}
jobs?: {
enabled: boolean
jobsDirPath: string
}
}
}
collections?: Partial<
Record<
CollectionSlug,
{
description: string
enabled:
| {
create?: boolean
delete?: boolean
find?: boolean
update?: boolean
}
| boolean
override?: (
original: Record<string, unknown>,
req: PayloadRequest,
) => Record<string, unknown>
}
>
>
disabled?: boolean
mcp?: {
handlerOptions?: MCPHandlerOptions
serverOptions?: MCPServerOptions
tools?: {
description: string
handler: (args: Record<string, unknown>) => Promise<{
content: Array<{
text: string
type: 'text'
}>
}>
name: string
parameters: z.ZodRawShape
}[]
}
}
export type MCPHandlerOptions = {
basePath?: string
maxDuration?: number
redisUrl?: string
verboseLogs?: boolean
}
export type MCPServerOptions = {
serverInfo?: {
name: string
version: string
}
}
export type ToolSettings = {
auth?: {
auth?: boolean
forgotPassword?: boolean
login?: boolean
resetPassword?: boolean
unlock?: boolean
verify?: boolean
}
collections?: {
create?: boolean
delete?: boolean
find?: boolean
update?: boolean
}
config?: {
find?: boolean
update?: boolean
}
custom?: Record<string, boolean>
jobs?: {
create?: boolean
run?: boolean
update?: boolean
}
} & Record<string, unknown>
export type FieldDefinition = {
description?: string
name: string
options?: { label: string; value: string }[]
position?: 'main' | 'sidebar'
required?: boolean
type: string
}
export type FieldModification = {
changes: {
description?: string
options?: { label: string; value: string }[]
position?: 'main' | 'sidebar'
required?: boolean
type?: string
}
fieldName: string
}
export type CollectionConfigUpdates = {
access?: {
create?: string
delete?: string
read?: string
update?: string
}
description?: string
slug?: string
timestamps?: boolean
versioning?: boolean
}
export type AdminConfig = {
avatar?: string
css?: string
dateFormat?: string
inactivityRoute?: string
livePreview?: {
breakpoints?: Array<{
height: number
label: string
name: string
width: number
}>
}
logoutRoute?: string
meta?: {
favicon?: string
ogImage?: string
titleSuffix?: string
}
user?: string
}
export type DatabaseConfig = {
connectOptions?: string
type?: 'mongodb' | 'postgres'
url?: string
}
export type PluginUpdates = {
add?: string[]
remove?: string[]
}
export type GeneralConfig = {
cookiePrefix?: string
cors?: string
csrf?: string
graphQL?: {
disable?: boolean
schemaOutputFile?: string
}
rateLimit?: {
max?: number
skip?: string
window?: number
}
secret?: string
serverURL?: string
typescript?: {
declare?: boolean
outputFile?: string
}
}
export interface SchemaField {
description?: string
name: string
options?: string[]
required?: boolean
type: string
}
export interface TaskSequenceItem {
description?: string
retries?: number
taskId: string
taskSlug: string
timeout?: number
}
export interface JobConfigUpdate {
description?: string
queue?: string
retries?: number
timeout?: number
}

View File

@@ -0,0 +1,12 @@
/**
* Converts a string to camel case by replacing dashes, underscores, and spaces
* with camel case formatting.
*
* @param str - The string to convert to camel case
* @returns The camel cased string
*/
export const toCamelCase = (str: string): string => {
return str
.replace(/[-_\s]+(.)?/g, (_, chr) => (chr ? chr.toUpperCase() : ''))
.replace(/^(.)/, (_, chr) => chr.toLowerCase())
}

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"references": [{ "path": "../payload" }, { "path": "../ui"}, { "path": "../translations"}]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-multi-tenant",
"version": "3.53.0",
"version": "3.54.0",
"description": "Multi Tenant plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-nested-docs",
"version": "3.53.0",
"version": "3.54.0",
"description": "The official Nested Docs plugin for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-redirects",
"version": "3.53.0",
"version": "3.54.0",
"description": "Redirects plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-search",
"version": "3.53.0",
"version": "3.54.0",
"description": "Search plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-sentry",
"version": "3.53.0",
"version": "3.54.0",
"description": "Sentry plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-seo",
"version": "3.53.0",
"version": "3.54.0",
"description": "SEO plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-stripe",
"version": "3.53.0",
"version": "3.54.0",
"description": "Stripe plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-lexical",
"version": "3.53.0",
"version": "3.54.0",
"description": "The officially supported Lexical richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-slate",
"version": "3.53.0",
"version": "3.54.0",
"description": "The officially supported Slate richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-azure",
"version": "3.53.0",
"version": "3.54.0",
"description": "Payload storage adapter for Azure Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-gcs",
"version": "3.53.0",
"version": "3.54.0",
"description": "Payload storage adapter for Google Cloud Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-s3",
"version": "3.53.0",
"version": "3.54.0",
"description": "Payload storage adapter for Amazon S3",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-uploadthing",
"version": "3.53.0",
"version": "3.54.0",
"description": "Payload storage adapter for uploadthing",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-vercel-blob",
"version": "3.53.0",
"version": "3.54.0",
"description": "Payload storage adapter for Vercel Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/translations",
"version": "3.53.0",
"version": "3.54.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/ui",
"version": "3.53.0",
"version": "3.54.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

561
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -44,6 +44,7 @@
"@payloadcms/plugin-cloud-storage": "workspace:*",
"@payloadcms/plugin-form-builder": "workspace:*",
"@payloadcms/plugin-import-export": "workspace:*",
"@payloadcms/plugin-mcp": "workspace:*",
"@payloadcms/plugin-multi-tenant": "workspace:*",
"@payloadcms/plugin-nested-docs": "workspace:*",
"@payloadcms/plugin-redirects": "workspace:*",
@@ -95,7 +96,8 @@
"tempy": "^1.0.1",
"ts-essentials": "10.0.3",
"typescript": "5.7.3",
"uuid": "10.0.0"
"uuid": "10.0.0",
"zod": "^3.25.5"
},
"pnpm": {
"neverBuiltDependencies": []

1
test/plugin-mcp/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
uploads

View File

@@ -0,0 +1,19 @@
import type { CollectionConfig } from 'payload'
export const ExampleProducts: CollectionConfig = {
slug: 'example-products',
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'description',
type: 'textarea',
},
{
name: 'price',
type: 'number',
},
],
}

View File

@@ -0,0 +1,15 @@
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
access: {
read: () => true,
},
fields: [
{
name: 'alt',
type: 'text',
},
],
upload: true,
}

View File

@@ -0,0 +1,20 @@
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'content',
type: 'text',
},
{
name: 'author',
type: 'relationship',
relationTo: 'users',
},
],
}

116
test/plugin-mcp/config.ts Normal file
View File

@@ -0,0 +1,116 @@
import { pluginMCP } from '@payloadcms/plugin-mcp'
import path from 'path'
import { fileURLToPath } from 'url'
import { z } from 'zod'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { ExampleProducts } from './collections/ExampleProducts.js'
import { Media } from './collections/Media.js'
import { Posts } from './collections/Posts.js'
import { seed } from './seed/index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [Media, Posts, ExampleProducts],
onInit: seed,
plugins: [
pluginMCP({
collections: {
[ExampleProducts.slug]: {
enabled: true,
},
posts: {
enabled: {
find: false,
create: true,
},
description: 'This is a Payload collection with Post documents.',
override: (original) => {
console.log('[Override MCP resource call for Posts]:', original)
return { ...original, myCustomOverrideProp: true }
},
},
media: {
enabled: {
find: true,
create: false,
update: true,
delete: false,
},
description: 'This is a Payload collection with Media documents.',
},
},
mcp: {
handlerOptions: {
verboseLogs: true,
maxDuration: 60,
},
serverOptions: {
serverInfo: {
name: 'My Custom MCP Server',
version: '1.0.0',
},
},
tools: [
{
name: 'diceRoll',
description: 'Rolls a virtual dice with a specified number of sides',
handler: (args: Record<string, unknown>) => {
const sides = (args.sides as number) || 6
const result = Math.floor(Math.random() * sides) + 1
return Promise.resolve({
content: [
{
type: 'text' as const,
text: `# Dice Roll Result\n\n**Sides:** ${sides}\n**Result:** ${result}\n\n🎲 You rolled a **${result}** on a ${sides}-sided die!`,
},
],
})
},
parameters: z.object({
sides: z
.number()
.int()
.min(2)
.max(1000)
.optional()
.default(6)
.describe('Number of sides on the dice (default: 6)'),
}).shape,
},
],
},
// Experimental MCP tools
_experimental: {
tools: {
collections: {
collectionsDirPath: 'test/plugin-mcp/collections',
enabled: true,
},
config: {
configFilePath: path.resolve(dirname, 'test/plugin-mcp/config.ts'),
enabled: true,
},
jobs: {
enabled: true,
jobsDirPath: 'dev/jobs',
},
auth: {
enabled: true,
},
},
},
}),
],
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

117
test/plugin-mcp/int.spec.ts Normal file
View File

@@ -0,0 +1,117 @@
import type { Payload } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import { devUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
let payload: Payload
let token: string
let restClient: NextRESTClient
const { email, password } = devUser
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('@payloadcms/plugin-mcp', () => {
beforeAll(async () => {
const initialized = await initPayloadInt(dirname)
;({ payload, restClient } = initialized)
const data = await restClient
.POST('/users/login', {
body: JSON.stringify({
email,
password,
}),
})
.then((res) => res.json())
token = data.token
})
afterAll(async () => {
await payload.destroy()
})
it('should not allow GET /api/mcp', async () => {
const data = await restClient
.GET(`/mcp`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then((res) => res.json())
expect(data).toBeDefined()
expect(data.jsonrpc).toBe('2.0')
expect(data.error).toBeDefined()
expect(data.error.code).toBe(-32000)
expect(data.error.message).toBe('Method not allowed.')
})
it('should list tools', async () => {
const request = new Request(`${restClient.serverURL}/api/mcp`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json, text/event-stream',
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({
id: 1,
jsonrpc: '2.0',
method: 'tools/list',
params: {},
}),
})
const response = await fetch(request)
const reader = response.body?.getReader()
const decoder = new TextDecoder()
let streamData = ''
while (true) {
// eslint-disable-next-line jest/no-conditional-in-test
const { done, value } = (await reader?.read()) || { done: false, value: new Uint8Array() }
// eslint-disable-next-line jest/no-conditional-in-test
if (done) {
break
}
streamData += decoder.decode(value, { stream: true })
}
const streamJSONDataLine = streamData
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('data:'))
.pop()
// eslint-disable-next-line jest/no-conditional-in-test
const streamJSONString = streamJSONDataLine
? streamJSONDataLine.slice('data:'.length).trim()
: streamData.trim()
const json = JSON.parse(streamJSONString)
expect(json).toBeDefined()
expect(json.id).toBe(1)
expect(json.jsonrpc).toBe('2.0')
expect(json.result).toBeDefined()
expect(json.result.tools).toBeDefined()
expect(json.result.tools).toHaveLength(2)
expect(json.result.tools[0].name).toBe('findResource')
expect(json.result.tools[0].description).toBe(
'Finds documents in a Payload collection using Find or FindByID. Possible collections are: media, posts',
)
expect(json.result.tools[0].inputSchema).toBeDefined()
expect(json.result.tools[1].name).toBe('diceRoll')
expect(json.result.tools[1].description).toBe(
'Rolls a virtual dice with a specified number of sides',
)
expect(json.result.tools[1].inputSchema).toBeDefined()
})
})

View File

@@ -0,0 +1,444 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
media: Media;
posts: Post;
'example-products': ExampleProduct;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
media: MediaSelect<false> | MediaSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>;
'example-products': ExampleProductsSelect<false> | ExampleProductsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
};
globals: {
'payload-mcp-tools': PayloadMcpTool;
};
globalsSelect: {
'payload-mcp-tools': PayloadMcpToolsSelect<false> | PayloadMcpToolsSelect<true>;
};
locale: null;
user: User & {
collection: 'users';
};
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: string;
alt?: string | null;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: string;
title?: string | null;
content?: string | null;
author?: (string | null) | User;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "example-products".
*/
export interface ExampleProduct {
id: string;
title?: string | null;
description?: string | null;
price?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'media';
value: string | Media;
} | null)
| ({
relationTo: 'posts';
value: string | Post;
} | null)
| ({
relationTo: 'example-products';
value: string | ExampleProduct;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
content?: T;
author?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "example-products_select".
*/
export interface ExampleProductsSelect<T extends boolean = true> {
title?: T;
description?: T;
price?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-mcp-tools".
*/
export interface PayloadMcpTool {
id: string;
'example-products-capabilities'?: {
/**
* Allow clients to find example-products.
*/
'example-products-find'?: boolean | null;
/**
* Allow clients to create example-products.
*/
'example-products-create'?: boolean | null;
/**
* Allow clients to update example-products.
*/
'example-products-update'?: boolean | null;
/**
* Allow clients to delete example-products.
*/
'example-products-delete'?: boolean | null;
};
'posts-capabilities'?: {
/**
* Allow clients to create posts.
*/
'posts-create'?: boolean | null;
};
'media-capabilities'?: {
/**
* Allow clients to find media.
*/
'media-find'?: boolean | null;
/**
* Allow clients to update media.
*/
'media-update'?: boolean | null;
};
custom?: {
/**
* Rolls a virtual dice with a specified number of sides
*/
diceRoll?: boolean | null;
};
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-mcp-tools_select".
*/
export interface PayloadMcpToolsSelect<T extends boolean = true> {
'example-products-capabilities'?:
| T
| {
'example-products-find'?: T;
'example-products-create'?: T;
'example-products-update'?: T;
'example-products-delete'?: T;
};
'posts-capabilities'?:
| T
| {
'posts-create'?: T;
};
'media-capabilities'?:
| T
| {
'media-find'?: T;
'media-update'?: T;
};
custom?:
| T
| {
diceRoll?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
// @ts-ignore
export interface GeneratedTypes extends Config {}
}

View File

@@ -0,0 +1,21 @@
import type { Payload } from 'payload'
import { devUser } from '../../credentials.js'
export const seed = async (payload: Payload) => {
const { totalDocs } = await payload.count({
collection: 'users',
where: {
email: {
equals: devUser.email,
},
},
})
if (!totalDocs) {
await payload.create({
collection: 'users',
data: devUser,
})
}
}

View File

@@ -0,0 +1,13 @@
{
// extend your base config to share compilerOptions, etc
//"extends": "./tsconfig.json",
"compilerOptions": {
// ensure that nobody can accidentally use this config for a build
"noEmit": true
},
"include": [
// whatever paths you intend to lint
"./**/*.ts",
"./**/*.tsx"
]
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../tsconfig.json"
}

View File

@@ -25,6 +25,7 @@ export const tgzToPkgNameMap = {
'@payloadcms/plugin-cloud-storage': 'payloadcms-plugin-cloud-storage-*',
'@payloadcms/plugin-form-builder': 'payloadcms-plugin-form-builder-*',
'@payloadcms/plugin-import-export': 'payloadcms-plugin-import-export-*',
'@payloadcms/plugin-mcp': 'payloadcms-plugin-mcp-*',
'@payloadcms/plugin-multi-tenant': 'payloadcms-plugin-multi-tenant-*',
'@payloadcms/plugin-nested-docs': 'payloadcms-plugin-nested-docs-*',
'@payloadcms/plugin-redirects': 'payloadcms-plugin-redirects-*',

View File

@@ -60,6 +60,7 @@
"@payloadcms/plugin-import-export/rsc": [
"./packages/plugin-import-export/src/exports/rsc.ts"
],
"@payloadcms/plugin-mcp": ["./packages/plugin-mcp/src/index.ts"],
"@payloadcms/plugin-multi-tenant/rsc": ["./packages/plugin-multi-tenant/src/exports/rsc.ts"],
"@payloadcms/plugin-multi-tenant/utilities": [
"./packages/plugin-multi-tenant/src/exports/utilities.ts"