Compare commits
24 Commits
docs-hooks
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c4f97364e | ||
|
|
46c0d3b827 | ||
|
|
ee16d3ec33 | ||
|
|
fe604c5f76 | ||
|
|
23c3c06a89 | ||
|
|
dfe73f40c2 | ||
|
|
65ab592f76 | ||
|
|
c7efe5b6c0 | ||
|
|
4b3b55fe2b | ||
|
|
0f6ce52856 | ||
|
|
3c70e62751 | ||
|
|
5d61b2dd40 | ||
|
|
ad02f40dc7 | ||
|
|
90a8097a48 | ||
|
|
de16fc09a4 | ||
|
|
bfde7d7fa6 | ||
|
|
b7d2bd394c | ||
|
|
7ffdc10351 | ||
|
|
2fa78bc9ec | ||
|
|
4b6bc9f9a2 | ||
|
|
92c01d948e | ||
|
|
5bd01ad87e | ||
|
|
f53114e0ea | ||
|
|
c1b4960795 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "payload-monorepo",
|
||||
"version": "3.53.0",
|
||||
"version": "3.54.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-payload-app",
|
||||
"version": "3.53.0",
|
||||
"version": "3.54.0",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/graphql",
|
||||
"version": "3.53.0",
|
||||
"version": "3.54.0",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/next",
|
||||
"version": "3.53.0",
|
||||
"version": "3.54.0",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
52
packages/plugin-mcp/.gitignore
vendored
Normal 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/
|
||||
|
||||
12
packages/plugin-mcp/.prettierignore
Normal file
12
packages/plugin-mcp/.prettierignore
Normal file
@@ -0,0 +1,12 @@
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
**/docs/**
|
||||
tsconfig.json
|
||||
24
packages/plugin-mcp/.swcrc
Normal file
24
packages/plugin-mcp/.swcrc
Normal 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"
|
||||
}
|
||||
}
|
||||
22
packages/plugin-mcp/LICENSE.md
Normal file
22
packages/plugin-mcp/LICENSE.md
Normal 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.
|
||||
112
packages/plugin-mcp/README.md
Normal file
112
packages/plugin-mcp/README.md
Normal 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_
|
||||
73
packages/plugin-mcp/package.json
Normal file
73
packages/plugin-mcp/package.json
Normal 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"
|
||||
}
|
||||
21
packages/plugin-mcp/src/endpoints/mcp.ts
Normal file
21
packages/plugin-mcp/src/endpoints/mcp.ts
Normal 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
|
||||
}
|
||||
352
packages/plugin-mcp/src/globals/createMcpToolsGlobal.ts
Normal file
352
packages/plugin-mcp/src/globals/createMcpToolsGlobal.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
59
packages/plugin-mcp/src/index.ts
Normal file
59
packages/plugin-mcp/src/index.ts
Normal 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
|
||||
}
|
||||
13
packages/plugin-mcp/src/mcp/createRequest.ts
Normal file
13
packages/plugin-mcp/src/mcp/createRequest.ts
Normal 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)
|
||||
}
|
||||
379
packages/plugin-mcp/src/mcp/getMcpHandler.ts
Normal file
379
packages/plugin-mcp/src/mcp/getMcpHandler.ts
Normal 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,
|
||||
},
|
||||
)
|
||||
}
|
||||
326
packages/plugin-mcp/src/mcp/helpers/config.ts
Normal file
326
packages/plugin-mcp/src/mcp/helpers/config.ts
Normal 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
|
||||
}
|
||||
3
packages/plugin-mcp/src/mcp/helpers/conversion.ts
Normal file
3
packages/plugin-mcp/src/mcp/helpers/conversion.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const toCamelCase = (str: string): string => {
|
||||
return str.replace(/-([a-z])/g, (match, letter) => letter.toUpperCase())
|
||||
}
|
||||
158
packages/plugin-mcp/src/mcp/helpers/fields.ts
Normal file
158
packages/plugin-mcp/src/mcp/helpers/fields.ts
Normal 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
|
||||
}
|
||||
417
packages/plugin-mcp/src/mcp/helpers/fileValidation.ts
Normal file
417
packages/plugin-mcp/src/mcp/helpers/fileValidation.ts
Normal 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')
|
||||
}
|
||||
32
packages/plugin-mcp/src/mcp/helpers/validation.ts
Normal file
32
packages/plugin-mcp/src/mcp/helpers/validation.ts
Normal 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`
|
||||
}
|
||||
}
|
||||
16
packages/plugin-mcp/src/mcp/registerTool.ts
Normal file
16
packages/plugin-mcp/src/mcp/registerTool.ts
Normal 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.`)
|
||||
}
|
||||
}
|
||||
69
packages/plugin-mcp/src/mcp/tools/auth/auth.ts
Normal file
69
packages/plugin-mcp/src/mcp/tools/auth/auth.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
68
packages/plugin-mcp/src/mcp/tools/auth/forgotPassword.ts
Normal file
68
packages/plugin-mcp/src/mcp/tools/auth/forgotPassword.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
70
packages/plugin-mcp/src/mcp/tools/auth/login.ts
Normal file
70
packages/plugin-mcp/src/mcp/tools/auth/login.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
59
packages/plugin-mcp/src/mcp/tools/auth/resetPassword.ts
Normal file
59
packages/plugin-mcp/src/mcp/tools/auth/resetPassword.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
62
packages/plugin-mcp/src/mcp/tools/auth/unlock.ts
Normal file
62
packages/plugin-mcp/src/mcp/tools/auth/unlock.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
55
packages/plugin-mcp/src/mcp/tools/auth/verify.ts
Normal file
55
packages/plugin-mcp/src/mcp/tools/auth/verify.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
236
packages/plugin-mcp/src/mcp/tools/collection/create.ts
Normal file
236
packages/plugin-mcp/src/mcp/tools/collection/create.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
227
packages/plugin-mcp/src/mcp/tools/collection/delete.ts
Normal file
227
packages/plugin-mcp/src/mcp/tools/collection/delete.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
222
packages/plugin-mcp/src/mcp/tools/collection/find.ts
Normal file
222
packages/plugin-mcp/src/mcp/tools/collection/find.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
288
packages/plugin-mcp/src/mcp/tools/collection/update.ts
Normal file
288
packages/plugin-mcp/src/mcp/tools/collection/update.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
126
packages/plugin-mcp/src/mcp/tools/config/find.ts
Normal file
126
packages/plugin-mcp/src/mcp/tools/config/find.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
282
packages/plugin-mcp/src/mcp/tools/config/update.ts
Normal file
282
packages/plugin-mcp/src/mcp/tools/config/update.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
420
packages/plugin-mcp/src/mcp/tools/job/create.ts
Normal file
420
packages/plugin-mcp/src/mcp/tools/job/create.ts
Normal 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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
189
packages/plugin-mcp/src/mcp/tools/job/run.ts
Normal file
189
packages/plugin-mcp/src/mcp/tools/job/run.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
319
packages/plugin-mcp/src/mcp/tools/job/update.ts
Normal file
319
packages/plugin-mcp/src/mcp/tools/job/update.ts
Normal 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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
95
packages/plugin-mcp/src/mcp/tools/resource/create.ts
Normal file
95
packages/plugin-mcp/src/mcp/tools/resource/create.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
165
packages/plugin-mcp/src/mcp/tools/resource/delete.ts
Normal file
165
packages/plugin-mcp/src/mcp/tools/resource/delete.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
150
packages/plugin-mcp/src/mcp/tools/resource/find.ts
Normal file
150
packages/plugin-mcp/src/mcp/tools/resource/find.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
211
packages/plugin-mcp/src/mcp/tools/resource/update.ts
Normal file
211
packages/plugin-mcp/src/mcp/tools/resource/update.ts
Normal 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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
378
packages/plugin-mcp/src/mcp/tools/schemas.ts
Normal file
378
packages/plugin-mcp/src/mcp/tools/schemas.ts
Normal 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")'),
|
||||
}),
|
||||
},
|
||||
}
|
||||
215
packages/plugin-mcp/src/types.ts
Normal file
215
packages/plugin-mcp/src/types.ts
Normal 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
|
||||
}
|
||||
12
packages/plugin-mcp/src/utils/camelCase.ts
Normal file
12
packages/plugin-mcp/src/utils/camelCase.ts
Normal 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())
|
||||
}
|
||||
4
packages/plugin-mcp/tsconfig.json
Normal file
4
packages/plugin-mcp/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"references": [{ "path": "../payload" }, { "path": "../ui"}, { "path": "../translations"}]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-redirects",
|
||||
"version": "3.53.0",
|
||||
"version": "3.54.0",
|
||||
"description": "Redirects plugin for Payload",
|
||||
"keywords": [
|
||||
"payload",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-search",
|
||||
"version": "3.53.0",
|
||||
"version": "3.54.0",
|
||||
"description": "Search plugin for Payload",
|
||||
"keywords": [
|
||||
"payload",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-sentry",
|
||||
"version": "3.53.0",
|
||||
"version": "3.54.0",
|
||||
"description": "Sentry plugin for Payload",
|
||||
"keywords": [
|
||||
"payload",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-seo",
|
||||
"version": "3.53.0",
|
||||
"version": "3.54.0",
|
||||
"description": "SEO plugin for Payload",
|
||||
"keywords": [
|
||||
"payload",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-stripe",
|
||||
"version": "3.53.0",
|
||||
"version": "3.54.0",
|
||||
"description": "Stripe plugin for Payload",
|
||||
"keywords": [
|
||||
"payload",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/translations",
|
||||
"version": "3.53.0",
|
||||
"version": "3.54.0",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -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
561
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
1
test/plugin-mcp/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
uploads
|
||||
19
test/plugin-mcp/collections/ExampleProducts.ts
Normal file
19
test/plugin-mcp/collections/ExampleProducts.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
15
test/plugin-mcp/collections/Media.ts
Normal file
15
test/plugin-mcp/collections/Media.ts
Normal 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,
|
||||
}
|
||||
20
test/plugin-mcp/collections/Posts.ts
Normal file
20
test/plugin-mcp/collections/Posts.ts
Normal 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
116
test/plugin-mcp/config.ts
Normal 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
117
test/plugin-mcp/int.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
444
test/plugin-mcp/payload-types.ts
Normal file
444
test/plugin-mcp/payload-types.ts
Normal 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 {}
|
||||
}
|
||||
21
test/plugin-mcp/seed/index.ts
Normal file
21
test/plugin-mcp/seed/index.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
13
test/plugin-mcp/tsconfig.eslint.json
Normal file
13
test/plugin-mcp/tsconfig.eslint.json
Normal 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"
|
||||
]
|
||||
}
|
||||
3
test/plugin-mcp/tsconfig.json
Normal file
3
test/plugin-mcp/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../tsconfig.json"
|
||||
}
|
||||
@@ -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-*',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user