├── .gitignore
├── .DS_Store
├── public
├── .DS_Store
├── logopayload.png
├── images
│ ├── matmax-logo.png
│ ├── linkedin-icon.svg
│ ├── instagram-icon.svg
│ └── github-icon.svg
├── 00Matmax-world-logo-_1_-svg.png
├── favicon.svg
├── robots.txt
├── sitemap.xml
├── logo-server.js
├── keep-alive.js
├── logo-generator.html
└── index.html.bak
├── railway.json
├── Dockerfile
├── railway.toml
├── .npmignore
├── api
├── health.ts
├── cron-keep-alive.ts
└── server.ts
├── scripts
├── test-client.mjs
└── update-logo.js
├── lib
├── payload
│ ├── index.ts
│ ├── types.ts
│ ├── query.ts
│ ├── schemas.ts
│ ├── sql.ts
│ ├── scaffolder.ts
│ ├── validator.ts
│ └── generator.ts
├── redis-connection.ts
└── mcp-api-handler.ts
├── server.js
├── LICENSE
├── .env.example
├── package.json
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .vercel
2 | node_modules
--------------------------------------------------------------------------------
/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/disruption-hub/payloadcmsmcp/HEAD/.DS_Store
--------------------------------------------------------------------------------
/public/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/disruption-hub/payloadcmsmcp/HEAD/public/.DS_Store
--------------------------------------------------------------------------------
/public/logopayload.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/disruption-hub/payloadcmsmcp/HEAD/public/logopayload.png
--------------------------------------------------------------------------------
/public/images/matmax-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/disruption-hub/payloadcmsmcp/HEAD/public/images/matmax-logo.png
--------------------------------------------------------------------------------
/public/00Matmax-world-logo-_1_-svg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/disruption-hub/payloadcmsmcp/HEAD/public/00Matmax-world-logo-_1_-svg.png
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | P
4 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # robots.txt for https://www.payloadcmsmcp.info/
2 |
3 | User-agent: *
4 | Allow: /
5 |
6 | # Sitemap location
7 | Sitemap: https://www.payloadcmsmcp.info/sitemap.xml
8 |
9 | # Disallow any potential admin or private areas
10 | Disallow: /admin/
11 | Disallow: /private/
12 | Disallow: /internal/
13 |
14 | # Crawl delay to prevent server overload
15 | Crawl-delay: 10
--------------------------------------------------------------------------------
/public/images/linkedin-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/public/images/instagram-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/railway.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://railway.app/railway.schema.json",
3 | "build": {
4 | "builder": "NIXPACKS",
5 | "buildCommand": "npm install && npm run build",
6 | "startCommand": "npm run start"
7 | },
8 | "deploy": {
9 | "healthcheckPath": "/health",
10 | "healthcheckTimeout": 100,
11 | "restartPolicyType": "ON_FAILURE",
12 | "restartPolicyMaxRetries": 10
13 | },
14 | "variables": {
15 | "PORT": "8080"
16 | }
17 | }
--------------------------------------------------------------------------------
/public/images/github-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile
2 | FROM node:18-alpine
3 |
4 | # Create app directory
5 | WORKDIR /app
6 |
7 | # Copy package.json and pnpm-lock.yaml
8 | COPY package.json pnpm-lock.yaml ./
9 |
10 | # Install pnpm
11 | RUN npm install -g pnpm
12 |
13 | # Install dependencies
14 | RUN pnpm install --no-frozen-lockfile
15 |
16 | # Copy the rest of the application
17 | COPY . .
18 |
19 | # Expose the port the app runs on
20 | EXPOSE 8080
21 |
22 | # Command to run the application
23 | CMD ["node", "server.js"]
24 |
--------------------------------------------------------------------------------
/railway.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | builder = "NIXPACKS"
3 | buildCommand = "npm install && npm run build"
4 | startCommand = "npm run start"
5 |
6 | [deploy]
7 | startCommand = "npm run start"
8 | restartPolicyType = "ON_FAILURE"
9 | restartPolicyMaxRetries = 10
10 |
11 | [[plugins]]
12 | name = "Postgres"
13 | envs = ["DATABASE_URL"]
14 |
15 | [template]
16 | name = "Payload CMS MCP Server"
17 | description = "A specialized MCP server for Payload CMS 3.0 that validates code, generates templates, and scaffolds projects following best practices."
18 | tags = ["nodejs", "express", "payload-cms", "mcp", "ai"]
19 | icon = "https://www.payloadcmsmcp.info/logopayload.png"
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | # Development files
2 | .git
3 | .github
4 | .gitignore
5 | .DS_Store
6 | .env
7 | .env.*
8 | .vscode
9 | .idea
10 | *.log
11 | logs
12 | *.swp
13 | *.swo
14 |
15 | # Build artifacts
16 | node_modules
17 | coverage
18 | .nyc_output
19 | .cache
20 | .vercel
21 | .railway
22 |
23 | # Documentation and examples
24 | docs
25 | examples
26 | test
27 | tests
28 | __tests__
29 | *.test.js
30 | *.spec.js
31 |
32 | # Miscellaneous
33 | .editorconfig
34 | .eslintrc
35 | .eslintignore
36 | .prettierrc
37 | .prettierignore
38 | .travis.yml
39 | .gitlab-ci.yml
40 | .circleci
41 | .github
42 | CONTRIBUTING.md
43 | CHANGELOG.md
44 | LICENSE.md
45 | current_html.html
46 | public/index.html.bak
47 | public/logo-generator.html
48 | public/logo-server.js
49 | scripts/update-logo.js
--------------------------------------------------------------------------------
/api/health.ts:
--------------------------------------------------------------------------------
1 | import type { VercelRequest, VercelResponse } from '@vercel/node';
2 | import { ensureRedisConnection } from '../lib/redis-connection';
3 |
4 | export default async function handler(req: VercelRequest, res: VercelResponse) {
5 | try {
6 | // Ensure Redis connection is established
7 | const isConnected = await ensureRedisConnection();
8 |
9 | res.status(200).json({
10 | status: isConnected ? 'ok' : 'redis_disconnected',
11 | timestamp: Date.now()
12 | });
13 | } catch (error) {
14 | console.error("Health check error:", error);
15 | res.status(500).json({
16 | status: 'error',
17 | message: error instanceof Error ? error.message : 'Unknown error',
18 | timestamp: Date.now()
19 | });
20 | }
21 | }
--------------------------------------------------------------------------------
/scripts/test-client.mjs:
--------------------------------------------------------------------------------
1 | import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
3 |
4 | const origin = process.argv[2] || "https://mcp-on-vercel.vercel.app";
5 |
6 | async function main() {
7 | const transport = new SSEClientTransport(new URL(`${origin}/sse`));
8 |
9 | const client = new Client(
10 | {
11 | name: "example-client",
12 | version: "1.0.0",
13 | },
14 | {
15 | capabilities: {
16 | prompts: {},
17 | resources: {},
18 | tools: {},
19 | },
20 | }
21 | );
22 |
23 | await client.connect(transport);
24 |
25 | console.log("Connected", client.getServerCapabilities());
26 |
27 | const result = await client.listTools();
28 | console.log(result);
29 | }
30 |
31 | main();
32 |
--------------------------------------------------------------------------------
/lib/payload/index.ts:
--------------------------------------------------------------------------------
1 | import { validatePayloadCode } from './validator';
2 | import { queryValidationRules } from './query';
3 | import { executeSqlQuery } from './sql';
4 | import { FileType } from './types';
5 | export * from './schemas';
6 | export * from './validator';
7 | export * from './query';
8 | export * from './generator';
9 | export * from './scaffolder';
10 |
11 | export { validatePayloadCode, queryValidationRules, executeSqlQuery, FileType };
12 |
13 | /**
14 | * Convenience function to validate Payload CMS code
15 | * @param code The code to validate
16 | * @param fileType The type of file to validate
17 | * @returns A boolean indicating if the code is valid
18 | */
19 | export function isValidPayloadCode(code: string, fileType: FileType): boolean {
20 | const result = validatePayloadCode(code, fileType);
21 | return result.isValid;
22 | }
--------------------------------------------------------------------------------
/lib/payload/types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Types for Payload CMS MCP Server
3 | */
4 |
5 | /**
6 | * File types that can be validated
7 | */
8 | export type FileType = 'collection' | 'field' | 'global' | 'config';
9 |
10 | /**
11 | * Validation result interface
12 | */
13 | export interface ValidationResult {
14 | isValid: boolean;
15 | errors: ValidationError[];
16 | }
17 |
18 | /**
19 | * Validation error interface
20 | */
21 | export interface ValidationError {
22 | message: string;
23 | path?: string;
24 | line?: number;
25 | column?: number;
26 | }
27 |
28 | /**
29 | * SQL Query result interface
30 | */
31 | export interface SqlQueryResult {
32 | columns: string[];
33 | rows: any[];
34 | }
35 |
36 | /**
37 | * Validation rule interface
38 | */
39 | export interface ValidationRule {
40 | id: string;
41 | name: string;
42 | description: string;
43 | category: string;
44 | fileTypes: FileType[];
45 | examples: {
46 | valid: string[];
47 | invalid: string[];
48 | };
49 | }
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const express = require('express');
4 | const path = require('path');
5 | const fs = require('fs');
6 |
7 | const app = express();
8 | const PORT = process.env.PORT || 8080;
9 |
10 | // Serve static files from the public directory
11 | app.use(express.static(path.join(__dirname, 'public')));
12 |
13 | // Simple health check endpoint
14 | app.get('/health', (req, res) => {
15 | res.status(200).json({ status: 'ok' });
16 | });
17 |
18 | // Handle all other routes by serving the index.html
19 | app.get('*', (req, res) => {
20 | res.sendFile(path.join(__dirname, 'public', 'index.html'));
21 | });
22 |
23 | // Start the server
24 | app.listen(PORT, () => {
25 | console.log(`Server running on port ${PORT}`);
26 | });
27 |
28 | // Handle process termination
29 | process.on('SIGINT', () => {
30 | console.log('Shutting down server...');
31 | process.exit(0);
32 | });
33 |
34 | process.on('SIGTERM', () => {
35 | console.log('Shutting down server...');
36 | process.exit(0);
37 | });
--------------------------------------------------------------------------------
/public/sitemap.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | https://www.payloadcmsmcp.info/
5 | 2023-11-15
6 | monthly
7 | 1.0
8 |
9 |
10 | https://www.payloadcmsmcp.info/api/validate
11 | 2023-11-15
12 | monthly
13 | 0.8
14 |
15 |
16 | https://www.payloadcmsmcp.info/api/query
17 | 2023-11-15
18 | monthly
19 | 0.8
20 |
21 |
22 | https://www.payloadcmsmcp.info/api/mcp_query
23 | 2023-11-15
24 | monthly
25 | 0.8
26 |
27 |
28 | https://www.payloadcmsmcp.info/sse
29 | 2023-11-15
30 | monthly
31 | 0.8
32 |
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 MATMAX WORLDWIDE
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/scripts/update-logo.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 |
4 | // Path to the index.html file
5 | const indexPath = path.join(__dirname, '..', 'public', 'index.html');
6 |
7 | // Read the index.html file
8 | fs.readFile(indexPath, 'utf8', (err, data) => {
9 | if (err) {
10 | console.error('Error reading index.html:', err);
11 | return;
12 | }
13 |
14 | // Replace the logo image source
15 | const updatedHtml = data.replace(
16 | / /g,
17 | ' '
18 | );
19 |
20 | // Write the updated HTML back to the file
21 | fs.writeFile(indexPath, updatedHtml, 'utf8', (err) => {
22 | if (err) {
23 | console.error('Error writing to index.html:', err);
24 | return;
25 | }
26 | console.log('Successfully updated the logo in index.html');
27 | console.log('Make sure to place your new logo file (matmax-payload-mcp-logo.png) in the public directory');
28 | });
29 | });
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Redis connection URL (required for MCP server functionality)
2 | # Use either REDIS_URL or KV_URL (Vercel KV)
3 | REDIS_URL=redis://username:password@host:port
4 | # KV_URL=redis://username:password@host:port
5 |
6 | # Vercel KV specific configuration
7 | # KV_REST_API_URL=https://your-kv-rest-api-url
8 | # KV_REST_API_TOKEN=your-kv-rest-api-token
9 | # KV_REST_API_READ_ONLY_TOKEN=your-kv-rest-api-read-only-token
10 |
11 | # Connection timeout in milliseconds (optional, default: 30000)
12 | # REDIS_CONNECT_TIMEOUT=30000
13 |
14 | # Keep-alive interval in milliseconds (optional, default: 5000)
15 | # REDIS_KEEP_ALIVE=5000
16 |
17 | # Heartbeat interval in milliseconds (optional, default: 30000)
18 | # REDIS_HEARTBEAT_INTERVAL=30000
19 |
20 | # Redis ping interval in milliseconds (optional, default: 1000)
21 | # REDIS_PING_INTERVAL=1000
22 |
23 | # Redis command timeout in milliseconds (optional, default: 5000)
24 | # REDIS_COMMAND_TIMEOUT=5000
25 |
26 | # Persistence check interval in milliseconds (optional, default: 60000)
27 | # REDIS_PERSISTENCE_INTERVAL=60000
28 |
29 | # Maximum reconnect attempts before forcing clean reconnection (optional, default: 5)
30 | # REDIS_MAX_RECONNECT_ATTEMPTS=5
31 |
32 | # Force TLS verification (optional, default: false for rediss:// URLs)
33 | # REDIS_TLS_VERIFY=false
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "payload-cms-mcp",
3 | "version": "1.0.2",
4 | "description": "Payload CMS 3.0 MCP Server - A specialized Model Context Protocol server for Payload CMS",
5 | "main": "server.js",
6 | "bin": {
7 | "payload-cms-mcp": "./server.js"
8 | },
9 | "scripts": {
10 | "test": "echo \"No tests specified\"",
11 | "start": "node server.js",
12 | "dev": "node server.js",
13 | "local": "node server.js",
14 | "prepublishOnly": "npm test"
15 | },
16 | "keywords": [
17 | "payload-cms",
18 | "mcp",
19 | "model-context-protocol",
20 | "ai",
21 | "cursor-ide",
22 | "headless-cms"
23 | ],
24 | "author": "MATMAX WORLDWIDE",
25 | "license": "MIT",
26 | "repository": {
27 | "type": "git",
28 | "url": "git+https://github.com/Matmax-Worldwide/payloadcmsmcp.git"
29 | },
30 | "bugs": {
31 | "url": "https://github.com/Matmax-Worldwide/payloadcmsmcp/issues"
32 | },
33 | "homepage": "https://www.payloadcmsmcp.info",
34 | "packageManager": "pnpm@8.15.7+sha512.c85cd21b6da10332156b1ca2aa79c0a61ee7ad2eb0453b88ab299289e9e8ca93e6091232b25c07cbf61f6df77128d9c849e5c9ac6e44854dbd211c49f3a67adc",
35 | "dependencies": {
36 | "@modelcontextprotocol/sdk": "^1.6.1",
37 | "content-type": "^1.0.5",
38 | "express": "^4.18.3",
39 | "http-proxy-middleware": "^2.0.6",
40 | "raw-body": "^3.0.0",
41 | "redis": "^4.7.0",
42 | "zod": "^3.24.2"
43 | },
44 | "devDependencies": {
45 | "@types/node": "^22.13.10",
46 | "vercel": "^41.4.0"
47 | },
48 | "engines": {
49 | "node": ">=18.0.0"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/public/logo-server.js:
--------------------------------------------------------------------------------
1 | const http = require('http');
2 | const fs = require('fs');
3 | const path = require('path');
4 |
5 | const PORT = 3000;
6 |
7 | const server = http.createServer((req, res) => {
8 | // Set CORS headers to allow access from any origin
9 | res.setHeader('Access-Control-Allow-Origin', '*');
10 | res.setHeader('Access-Control-Allow-Methods', 'GET');
11 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
12 |
13 | // Handle the root path
14 | if (req.url === '/' || req.url === '/logo-generator') {
15 | fs.readFile(path.join(__dirname, 'logo-generator.html'), (err, content) => {
16 | if (err) {
17 | res.writeHead(500);
18 | res.end(`Error loading logo generator: ${err.message}`);
19 | return;
20 | }
21 | res.writeHead(200, { 'Content-Type': 'text/html' });
22 | res.end(content);
23 | });
24 | return;
25 | }
26 |
27 | // Handle requests for files in the public directory
28 | const filePath = path.join(__dirname, req.url);
29 | fs.readFile(filePath, (err, content) => {
30 | if (err) {
31 | res.writeHead(404);
32 | res.end(`File not found: ${req.url}`);
33 | return;
34 | }
35 |
36 | // Determine the content type based on file extension
37 | let contentType = 'text/plain';
38 | const ext = path.extname(filePath);
39 | switch (ext) {
40 | case '.html':
41 | contentType = 'text/html';
42 | break;
43 | case '.js':
44 | contentType = 'text/javascript';
45 | break;
46 | case '.css':
47 | contentType = 'text/css';
48 | break;
49 | case '.json':
50 | contentType = 'application/json';
51 | break;
52 | case '.png':
53 | contentType = 'image/png';
54 | break;
55 | case '.jpg':
56 | case '.jpeg':
57 | contentType = 'image/jpeg';
58 | break;
59 | case '.svg':
60 | contentType = 'image/svg+xml';
61 | break;
62 | }
63 |
64 | res.writeHead(200, { 'Content-Type': contentType });
65 | res.end(content);
66 | });
67 | });
68 |
69 | server.listen(PORT, () => {
70 | console.log(`Logo generator server running at http://localhost:${PORT}`);
71 | console.log(`Open http://localhost:${PORT}/logo-generator in your browser to create your logo`);
72 | });
--------------------------------------------------------------------------------
/public/keep-alive.js:
--------------------------------------------------------------------------------
1 | // Script to keep the Redis connection alive by pinging the keep-alive endpoint
2 | (function() {
3 | // Configuration
4 | const PING_INTERVAL = 15000; // 15 seconds
5 | const KEEP_ALIVE_URL = '/api/keep-alive';
6 | const MAX_CONSECUTIVE_FAILURES = 3;
7 |
8 | let consecutiveFailures = 0;
9 | let lastPingTime = 0;
10 | let isActive = true;
11 |
12 | // Function to ping the keep-alive endpoint
13 | async function pingKeepAlive() {
14 | if (!isActive) return;
15 |
16 | try {
17 | const now = Date.now();
18 | const timeSinceLastPing = lastPingTime ? now - lastPingTime : 0;
19 | lastPingTime = now;
20 |
21 | console.log(`Pinging keep-alive endpoint. Time since last ping: ${timeSinceLastPing}ms`);
22 |
23 | const response = await fetch(KEEP_ALIVE_URL);
24 | if (!response.ok) {
25 | throw new Error(`HTTP error! status: ${response.status}`);
26 | }
27 |
28 | const data = await response.json();
29 | console.log('Keep-alive response:', data);
30 |
31 | // Reset failure counter on success
32 | consecutiveFailures = 0;
33 |
34 | } catch (error) {
35 | console.error('Keep-alive ping failed:', error);
36 | consecutiveFailures++;
37 |
38 | if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
39 | console.warn(`${MAX_CONSECUTIVE_FAILURES} consecutive failures. Increasing ping frequency.`);
40 | // If we're having trouble, ping more frequently
41 | setTimeout(pingKeepAlive, PING_INTERVAL / 2);
42 | }
43 | }
44 |
45 | // Schedule next ping
46 | setTimeout(pingKeepAlive, PING_INTERVAL);
47 | }
48 |
49 | // Start pinging when the page loads
50 | window.addEventListener('load', () => {
51 | console.log('Starting keep-alive pings...');
52 | pingKeepAlive();
53 | });
54 |
55 | // Pause pinging when the page is not visible
56 | document.addEventListener('visibilitychange', () => {
57 | if (document.visibilityState === 'hidden') {
58 | console.log('Page hidden, pausing keep-alive pings');
59 | isActive = false;
60 | } else {
61 | console.log('Page visible, resuming keep-alive pings');
62 | isActive = true;
63 | // Ping immediately when becoming visible again
64 | pingKeepAlive();
65 | }
66 | });
67 |
68 | // Also ping on user interaction to ensure activity
69 | ['click', 'keydown', 'mousemove', 'touchstart'].forEach(eventType => {
70 | document.addEventListener(eventType, () => {
71 | // Only ping if it's been at least half the interval since the last ping
72 | const now = Date.now();
73 | if (now - lastPingTime > PING_INTERVAL / 2) {
74 | pingKeepAlive();
75 | }
76 | }, { passive: true });
77 | });
78 | })();
--------------------------------------------------------------------------------
/public/logo-generator.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Matmax Logo Generator
7 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | PAYLOAD CMS
79 | MCP SERVER
80 |
81 |
82 |
83 |
84 |
85 |
89 |
90 |
91 |
134 |
135 |
136 |
137 |
138 |
--------------------------------------------------------------------------------
/lib/payload/query.ts:
--------------------------------------------------------------------------------
1 | import { validationRules } from './validator';
2 | import { FileType, ValidationRule } from './types';
3 |
4 | /**
5 | * Query validation rules based on a search term
6 | * @param query The search query
7 | * @param fileType Optional file type to filter by
8 | * @returns Matching validation rules
9 | */
10 | export function queryValidationRules(query: string, fileType?: FileType): ValidationRule[] {
11 | // Normalize the query
12 | const normalizedQuery = query.toLowerCase().trim();
13 |
14 | // If the query is empty, return all rules (filtered by fileType if provided)
15 | if (!normalizedQuery) {
16 | return fileType
17 | ? validationRules.filter(rule => rule.fileTypes.includes(fileType))
18 | : validationRules;
19 | }
20 |
21 | // Search for matching rules
22 | return validationRules.filter(rule => {
23 | // Filter by fileType if provided
24 | if (fileType && !rule.fileTypes.includes(fileType)) {
25 | return false;
26 | }
27 |
28 | // Check if the query matches any of the rule's properties
29 | return (
30 | rule.id.toLowerCase().includes(normalizedQuery) ||
31 | rule.name.toLowerCase().includes(normalizedQuery) ||
32 | rule.description.toLowerCase().includes(normalizedQuery) ||
33 | rule.category.toLowerCase().includes(normalizedQuery)
34 | );
35 | });
36 | }
37 |
38 | /**
39 | * Get a validation rule by ID
40 | * @param id The rule ID
41 | * @returns The validation rule or undefined if not found
42 | */
43 | export function getValidationRuleById(id: string): ValidationRule | undefined {
44 | return validationRules.find(rule => rule.id === id);
45 | }
46 |
47 | /**
48 | * Get validation rules by category
49 | * @param category The category to filter by
50 | * @returns Validation rules in the specified category
51 | */
52 | export function getValidationRulesByCategory(category: string): ValidationRule[] {
53 | return validationRules.filter(rule => rule.category === category);
54 | }
55 |
56 | /**
57 | * Get validation rules by file type
58 | * @param fileType The file type to filter by
59 | * @returns Validation rules applicable to the specified file type
60 | */
61 | export function getValidationRulesByFileType(fileType: FileType): ValidationRule[] {
62 | return validationRules.filter(rule => rule.fileTypes.includes(fileType));
63 | }
64 |
65 | /**
66 | * Get all available categories
67 | * @returns Array of unique categories
68 | */
69 | export function getCategories(): string[] {
70 | const categories = new Set();
71 | validationRules.forEach(rule => categories.add(rule.category));
72 | return Array.from(categories);
73 | }
74 |
75 | /**
76 | * Get validation rules with examples
77 | * @param query Optional search query
78 | * @param fileType Optional file type to filter by
79 | * @returns Validation rules with examples
80 | */
81 | export function getValidationRulesWithExamples(query?: string, fileType?: FileType): ValidationRule[] {
82 | return query ? queryValidationRules(query, fileType) :
83 | fileType ? getValidationRulesByFileType(fileType) : validationRules;
84 | }
85 |
86 | /**
87 | * Execute an SQL-like query against validation rules
88 | */
89 | export const executeSqlQuery = (sqlQuery: string): any[] => {
90 | // This is a very simplified SQL parser
91 | // In a real implementation, you'd use a proper SQL parser
92 |
93 | const selectMatch = sqlQuery.match(/SELECT\s+(.*?)\s+FROM\s+(.*?)(?:\s+WHERE\s+(.*?))?(?:\s+ORDER\s+BY\s+(.*?))?(?:\s+LIMIT\s+(\d+))?$/i);
94 |
95 | if (!selectMatch) {
96 | throw new Error('Invalid SQL query format');
97 | }
98 |
99 | const [, selectClause, fromClause, whereClause, orderByClause, limitClause] = selectMatch;
100 |
101 | // Check if we're querying validation_rules
102 | if (fromClause.trim().toLowerCase() !== 'validation_rules') {
103 | throw new Error('Only validation_rules table is supported');
104 | }
105 |
106 | // Process SELECT clause
107 | const selectAll = selectClause.trim() === '*';
108 | const selectedFields = selectAll
109 | ? ['id', 'description', 'type', 'category', 'severity', 'documentation']
110 | : selectClause.split(',').map(f => f.trim());
111 |
112 | // Process WHERE clause
113 | let filteredRules = [...validationRules];
114 | if (whereClause) {
115 | // Very simple WHERE parser that handles basic conditions
116 | const conditions = whereClause.split(/\s+AND\s+/i);
117 |
118 | filteredRules = filteredRules.filter(rule => {
119 | return conditions.every(condition => {
120 | const equalityMatch = condition.match(/(\w+)\s*=\s*['"]?(.*?)['"]?$/i);
121 | const likeMatch = condition.match(/(\w+)\s+LIKE\s+['"]%(.*?)%['"]/i);
122 |
123 | if (equalityMatch) {
124 | const [, field, value] = equalityMatch;
125 | return rule[field as keyof ValidationRule]?.toString().toLowerCase() === value.toLowerCase();
126 | } else if (likeMatch) {
127 | const [, field, value] = likeMatch;
128 | return rule[field as keyof ValidationRule]?.toString().toLowerCase().includes(value.toLowerCase());
129 | }
130 |
131 | return true;
132 | });
133 | });
134 | }
135 |
136 | // Process ORDER BY clause
137 | if (orderByClause) {
138 | const [field, direction] = orderByClause.split(/\s+/);
139 | const isDesc = direction?.toUpperCase() === 'DESC';
140 |
141 | filteredRules.sort((a, b) => {
142 | const aValue = a[field.trim() as keyof ValidationRule];
143 | const bValue = b[field.trim() as keyof ValidationRule];
144 |
145 | if (aValue < bValue) return isDesc ? 1 : -1;
146 | if (aValue > bValue) return isDesc ? -1 : 1;
147 | return 0;
148 | });
149 | }
150 |
151 | // Process LIMIT clause
152 | if (limitClause) {
153 | filteredRules = filteredRules.slice(0, parseInt(limitClause, 10));
154 | }
155 |
156 | // Project selected fields
157 | return filteredRules.map(rule => {
158 | const result: Record = {};
159 | selectedFields.forEach(field => {
160 | result[field] = rule[field as keyof ValidationRule];
161 | });
162 | return result;
163 | });
164 | };
--------------------------------------------------------------------------------
/api/cron-keep-alive.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from "redis";
2 | import type { VercelRequest, VercelResponse } from '@vercel/node';
3 |
4 | // Global Redis client that persists between function invocations
5 | let redisClient: any = null;
6 | let lastPingTime = 0;
7 | let connectionAttempts = 0;
8 | const MAX_CONNECTION_ATTEMPTS = 10;
9 |
10 | // Clean the Redis URL if it contains variable name or quotes
11 | const cleanRedisUrl = (url: string | undefined): string | undefined => {
12 | if (!url) return undefined;
13 |
14 | // If the URL contains KV_URL= or REDIS_URL=, extract just the URL part
15 | if (url.includes('KV_URL=') || url.includes('REDIS_URL=')) {
16 | const match = url.match(/(?:KV_URL=|REDIS_URL=)["']?(rediss?:\/\/[^"']+)["']?/);
17 | return match ? match[1] : url;
18 | }
19 |
20 | // Remove any surrounding quotes
21 | return url.replace(/^["'](.+)["']$/, '$1');
22 | };
23 |
24 | // Initialize Redis client if not already initialized
25 | async function getRedisClient() {
26 | if (redisClient && await isRedisConnected()) {
27 | console.log("Using existing Redis client");
28 | connectionAttempts = 0;
29 | return redisClient;
30 | }
31 |
32 | console.log("Creating new Redis client");
33 | connectionAttempts++;
34 |
35 | if (connectionAttempts > MAX_CONNECTION_ATTEMPTS) {
36 | console.log(`Exceeded maximum connection attempts (${MAX_CONNECTION_ATTEMPTS}). Resetting counter.`);
37 | connectionAttempts = 1;
38 | }
39 |
40 | const redisUrl = cleanRedisUrl(process.env.REDIS_URL) || cleanRedisUrl(process.env.KV_URL);
41 | if (!redisUrl) {
42 | throw new Error("REDIS_URL or KV_URL environment variable is not set");
43 | }
44 |
45 | // Create Redis client with maximum persistence settings
46 | redisClient = createClient({
47 | url: redisUrl,
48 | socket: {
49 | reconnectStrategy: (retries) => {
50 | const delay = Math.min(Math.pow(1.5, retries) * 100, 5000);
51 | console.log(`Redis reconnecting in ${delay}ms (attempt ${retries})`);
52 | return delay;
53 | },
54 | connectTimeout: 30000,
55 | keepAlive: 5000,
56 | noDelay: true,
57 | tls: redisUrl.startsWith('rediss://') ? { rejectUnauthorized: false } : undefined,
58 | },
59 | pingInterval: 1000,
60 | disableOfflineQueue: false,
61 | commandTimeout: 5000,
62 | retryStrategy: () => 1000, // Retry every second
63 | autoResubscribe: true,
64 | autoResendUnfulfilledCommands: true,
65 | enableReadyCheck: true,
66 | enableOfflineQueue: true,
67 | maxRetriesPerRequest: 50,
68 | });
69 |
70 | redisClient.on("error", (err: Error) => {
71 | console.error("Redis error", err);
72 | // Don't set redisClient to null here, let the reconnection strategy handle it
73 | });
74 |
75 | redisClient.on("connect", () => {
76 | console.log("Redis connected");
77 | });
78 |
79 | redisClient.on("ready", () => {
80 | console.log("Redis ready");
81 | });
82 |
83 | redisClient.on("end", () => {
84 | console.log("Redis disconnected");
85 | redisClient = null; // Reset client so we create a new one next time
86 | });
87 |
88 | try {
89 | await redisClient.connect();
90 |
91 | // Set up a ping interval to keep the connection alive
92 | setInterval(async () => {
93 | try {
94 | if (redisClient) {
95 | await redisClient.ping();
96 | console.log("Internal ping successful");
97 | }
98 | } catch (error) {
99 | console.error("Internal ping failed:", error);
100 | }
101 | }, 10000); // Ping every 10 seconds
102 |
103 | } catch (error) {
104 | console.error("Failed to connect to Redis:", error);
105 | redisClient = null;
106 | throw error;
107 | }
108 |
109 | return redisClient;
110 | }
111 |
112 | // Check if Redis is connected
113 | async function isRedisConnected() {
114 | if (!redisClient) return false;
115 |
116 | try {
117 | await redisClient.ping();
118 | return true;
119 | } catch (error) {
120 | console.error("Redis connection check failed:", error);
121 | return false;
122 | }
123 | }
124 |
125 | // Perform Redis operations to keep the connection active
126 | async function performRedisOperations(redis: any) {
127 | const now = Date.now();
128 |
129 | // Store the current time in Redis
130 | await redis.set('last_cron_keepalive', now.toString());
131 |
132 | // Get the stored value
133 | const storedValue = await redis.get('last_cron_keepalive');
134 |
135 | // Get some stats
136 | const info = await redis.info();
137 |
138 | // Perform a simple list operation
139 | await redis.lPush('keepalive_list', now.toString());
140 | await redis.lTrim('keepalive_list', 0, 9); // Keep only the last 10 entries
141 |
142 | // Get the list
143 | const list = await redis.lRange('keepalive_list', 0, -1);
144 |
145 | return {
146 | storedValue,
147 | info: info.substring(0, 500) + '...', // Truncate info to avoid large response
148 | list
149 | };
150 | }
151 |
152 | // Handler for the cron endpoint
153 | export default async function handler(req: VercelRequest, res: VercelResponse) {
154 | try {
155 | // Check for authorization header if needed
156 | const authHeader = req.headers.authorization;
157 | if (process.env.CRON_SECRET && authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
158 | return res.status(401).json({ error: 'Unauthorized' });
159 | }
160 |
161 | const now = Date.now();
162 | const timeSinceLastPing = now - lastPingTime;
163 | lastPingTime = now;
164 |
165 | console.log(`Cron keep-alive endpoint called. Time since last ping: ${timeSinceLastPing}ms`);
166 |
167 | const redis = await getRedisClient();
168 | const pingResult = await redis.ping();
169 |
170 | // Perform various Redis operations to ensure the connection stays active
171 | const operationResults = await performRedisOperations(redis);
172 |
173 | res.status(200).json({
174 | status: 'ok',
175 | ping: pingResult,
176 | lastPingTime: lastPingTime,
177 | timeSinceLastPing: timeSinceLastPing,
178 | connectionAttempts,
179 | operations: operationResults,
180 | timestamp: now
181 | });
182 | } catch (error) {
183 | console.error("Cron keep-alive error:", error);
184 | res.status(500).json({
185 | status: 'error',
186 | message: error instanceof Error ? error.message : 'Unknown error',
187 | timestamp: Date.now()
188 | });
189 | }
190 | }
--------------------------------------------------------------------------------
/lib/redis-connection.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from "redis";
2 |
3 | // Global Redis client that persists between function invocations
4 | let redisClient: any = null;
5 | let redisPublisherClient: any = null;
6 | let connectionAttempts = 0;
7 | let isInitialized = false;
8 | let pingIntervalId: NodeJS.Timeout | null = null;
9 |
10 | // Clean the Redis URL if it contains variable name or quotes
11 | const cleanRedisUrl = (url: string | undefined): string | undefined => {
12 | if (!url) return undefined;
13 |
14 | // If the URL contains KV_URL= or REDIS_URL=, extract just the URL part
15 | if (url.includes('KV_URL=') || url.includes('REDIS_URL=')) {
16 | const match = url.match(/(?:KV_URL=|REDIS_URL=)["']?(rediss?:\/\/[^"']+)["']?/);
17 | return match ? match[1] : url;
18 | }
19 |
20 | // Remove any surrounding quotes
21 | return url.replace(/^["'](.+)["']$/, '$1');
22 | };
23 |
24 | // Initialize Redis client if not already initialized
25 | export async function getRedisClient() {
26 | if (redisClient && await isRedisConnected(redisClient)) {
27 | console.log("Using existing Redis client");
28 | connectionAttempts = 0;
29 | return redisClient;
30 | }
31 |
32 | console.log("Creating new Redis client");
33 | connectionAttempts++;
34 |
35 | const redisUrl = cleanRedisUrl(process.env.REDIS_URL) || cleanRedisUrl(process.env.KV_URL);
36 | if (!redisUrl) {
37 | throw new Error("REDIS_URL or KV_URL environment variable is not set");
38 | }
39 |
40 | // Create Redis client with maximum persistence settings
41 | redisClient = createClient({
42 | url: redisUrl,
43 | socket: {
44 | reconnectStrategy: (retries) => {
45 | const delay = Math.min(Math.pow(1.5, retries) * 100, 5000);
46 | console.log(`Redis reconnecting in ${delay}ms (attempt ${retries})`);
47 | return delay;
48 | },
49 | connectTimeout: 30000,
50 | keepAlive: 5000,
51 | noDelay: true,
52 | tls: redisUrl.startsWith('rediss://') ? { rejectUnauthorized: false } : undefined,
53 | },
54 | pingInterval: 1000,
55 | disableOfflineQueue: false,
56 | commandTimeout: 5000,
57 | retryStrategy: () => 1000, // Retry every second
58 | autoResubscribe: true,
59 | autoResendUnfulfilledCommands: true,
60 | enableReadyCheck: true,
61 | enableOfflineQueue: true,
62 | maxRetriesPerRequest: 50,
63 | });
64 |
65 | setupRedisEventHandlers(redisClient, 'Redis');
66 |
67 | try {
68 | await redisClient.connect();
69 |
70 | // Set up a ping interval to keep the connection alive if not already set
71 | if (!isInitialized) {
72 | setupPingInterval();
73 | isInitialized = true;
74 | }
75 |
76 | } catch (error) {
77 | console.error("Failed to connect to Redis:", error);
78 | redisClient = null;
79 | throw error;
80 | }
81 |
82 | return redisClient;
83 | }
84 |
85 | // Get or create Redis publisher client
86 | export async function getRedisPublisherClient() {
87 | if (redisPublisherClient && await isRedisConnected(redisPublisherClient)) {
88 | console.log("Using existing Redis publisher client");
89 | return redisPublisherClient;
90 | }
91 |
92 | console.log("Creating new Redis publisher client");
93 |
94 | const redisUrl = cleanRedisUrl(process.env.REDIS_URL) || cleanRedisUrl(process.env.KV_URL);
95 | if (!redisUrl) {
96 | throw new Error("REDIS_URL or KV_URL environment variable is not set");
97 | }
98 |
99 | // Create Redis publisher client with maximum persistence settings
100 | redisPublisherClient = createClient({
101 | url: redisUrl,
102 | socket: {
103 | reconnectStrategy: (retries) => {
104 | const delay = Math.min(Math.pow(1.5, retries) * 100, 5000);
105 | console.log(`Redis publisher reconnecting in ${delay}ms (attempt ${retries})`);
106 | return delay;
107 | },
108 | connectTimeout: 30000,
109 | keepAlive: 5000,
110 | noDelay: true,
111 | tls: redisUrl.startsWith('rediss://') ? { rejectUnauthorized: false } : undefined,
112 | },
113 | pingInterval: 1000,
114 | disableOfflineQueue: false,
115 | commandTimeout: 5000,
116 | retryStrategy: () => 1000, // Retry every second
117 | autoResubscribe: true,
118 | autoResendUnfulfilledCommands: true,
119 | enableReadyCheck: true,
120 | enableOfflineQueue: true,
121 | maxRetriesPerRequest: 50,
122 | });
123 |
124 | setupRedisEventHandlers(redisPublisherClient, 'Redis Publisher');
125 |
126 | try {
127 | await redisPublisherClient.connect();
128 | } catch (error) {
129 | console.error("Failed to connect to Redis publisher:", error);
130 | redisPublisherClient = null;
131 | throw error;
132 | }
133 |
134 | return redisPublisherClient;
135 | }
136 |
137 | // Set up Redis event handlers
138 | function setupRedisEventHandlers(client: any, clientName: string) {
139 | client.on("error", (err: Error) => {
140 | console.error(`${clientName} error:`, err);
141 | });
142 |
143 | client.on("connect", () => {
144 | console.log(`${clientName} connected`);
145 | });
146 |
147 | client.on("ready", () => {
148 | console.log(`${clientName} ready`);
149 | });
150 |
151 | client.on("reconnecting", () => {
152 | console.log(`${clientName} reconnecting...`);
153 | });
154 |
155 | client.on("end", () => {
156 | console.log(`${clientName} disconnected`);
157 | if (clientName === 'Redis') {
158 | redisClient = null;
159 | } else {
160 | redisPublisherClient = null;
161 | }
162 | });
163 | }
164 |
165 | // Check if Redis is connected
166 | async function isRedisConnected(client: any) {
167 | if (!client) return false;
168 |
169 | try {
170 | await client.ping();
171 | return true;
172 | } catch (error) {
173 | console.error("Redis connection check failed:", error);
174 | return false;
175 | }
176 | }
177 |
178 | // Set up a ping interval to keep the connection alive
179 | function setupPingInterval() {
180 | if (pingIntervalId) {
181 | clearInterval(pingIntervalId);
182 | }
183 |
184 | pingIntervalId = setInterval(async () => {
185 | try {
186 | // Ping the main Redis client
187 | if (redisClient) {
188 | await redisClient.ping();
189 | console.log("Redis ping successful");
190 | }
191 |
192 | // Ping the publisher client if it exists
193 | if (redisPublisherClient) {
194 | await redisPublisherClient.ping();
195 | console.log("Redis publisher ping successful");
196 | }
197 |
198 | // If either client is null, try to reconnect
199 | if (!redisClient) {
200 | console.log("Redis client is null, attempting to reconnect");
201 | try {
202 | await getRedisClient();
203 | } catch (error) {
204 | console.error("Failed to reconnect Redis client:", error);
205 | }
206 | }
207 |
208 | if (!redisPublisherClient) {
209 | console.log("Redis publisher client is null, attempting to reconnect");
210 | try {
211 | await getRedisPublisherClient();
212 | } catch (error) {
213 | console.error("Failed to reconnect Redis publisher client:", error);
214 | }
215 | }
216 |
217 | } catch (error) {
218 | console.error("Ping interval error:", error);
219 |
220 | // Try to reconnect if ping fails
221 | try {
222 | if (redisClient) {
223 | await redisClient.disconnect();
224 | redisClient = null;
225 | }
226 | if (redisPublisherClient) {
227 | await redisPublisherClient.disconnect();
228 | redisPublisherClient = null;
229 | }
230 |
231 | await getRedisClient();
232 | await getRedisPublisherClient();
233 | } catch (reconnectError) {
234 | console.error("Failed to reconnect after ping failure:", reconnectError);
235 | }
236 | }
237 | }, 10000); // Ping every 10 seconds
238 |
239 | // Ensure the interval is cleaned up on process exit
240 | process.on('beforeExit', () => {
241 | if (pingIntervalId) {
242 | clearInterval(pingIntervalId);
243 | }
244 | });
245 | }
246 |
247 | // Initialize the Redis connection immediately
248 | getRedisClient().catch(error => {
249 | console.error("Initial Redis connection failed:", error);
250 | });
251 |
252 | // Export a function to ensure Redis connection
253 | export async function ensureRedisConnection() {
254 | try {
255 | await getRedisClient();
256 | await getRedisPublisherClient();
257 | return true;
258 | } catch (error) {
259 | console.error("Failed to ensure Redis connection:", error);
260 | return false;
261 | }
262 | }
--------------------------------------------------------------------------------
/lib/payload/schemas.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 |
3 | // Field types supported by Payload CMS 3.0
4 | export const FieldTypes = [
5 | 'text',
6 | 'textarea',
7 | 'email',
8 | 'code',
9 | 'number',
10 | 'date',
11 | 'checkbox',
12 | 'select',
13 | 'relationship',
14 | 'upload',
15 | 'array',
16 | 'blocks',
17 | 'group',
18 | 'row',
19 | 'collapsible',
20 | 'tabs',
21 | 'richText',
22 | 'json',
23 | 'radio',
24 | 'point',
25 | ] as const;
26 |
27 | // Base field schema that all field types extend
28 | export const BaseFieldSchema = z.object({
29 | name: z.string().min(1),
30 | label: z.string().optional(),
31 | required: z.boolean().optional(),
32 | unique: z.boolean().optional(),
33 | index: z.boolean().optional(),
34 | defaultValue: z.any().optional(),
35 | hidden: z.boolean().optional(),
36 | saveToJWT: z.boolean().optional(),
37 | localized: z.boolean().optional(),
38 | validate: z.function().optional(),
39 | hooks: z.object({
40 | beforeValidate: z.function().optional(),
41 | beforeChange: z.function().optional(),
42 | afterChange: z.function().optional(),
43 | afterRead: z.function().optional(),
44 | }).optional(),
45 | admin: z.object({
46 | position: z.string().optional(),
47 | width: z.string().optional(),
48 | style: z.record(z.any()).optional(),
49 | className: z.string().optional(),
50 | readOnly: z.boolean().optional(),
51 | hidden: z.boolean().optional(),
52 | description: z.string().optional(),
53 | condition: z.function().optional(),
54 | components: z.record(z.any()).optional(),
55 | }).optional(),
56 | access: z.object({
57 | read: z.union([z.function(), z.boolean()]).optional(),
58 | create: z.union([z.function(), z.boolean()]).optional(),
59 | update: z.union([z.function(), z.boolean()]).optional(),
60 | }).optional(),
61 | });
62 |
63 | // Text field schema
64 | export const TextFieldSchema = BaseFieldSchema.extend({
65 | type: z.literal('text'),
66 | minLength: z.number().optional(),
67 | maxLength: z.number().optional(),
68 | hasMany: z.boolean().optional(),
69 | });
70 |
71 | // Number field schema
72 | export const NumberFieldSchema = BaseFieldSchema.extend({
73 | type: z.literal('number'),
74 | min: z.number().optional(),
75 | max: z.number().optional(),
76 | hasMany: z.boolean().optional(),
77 | });
78 |
79 | // Select field schema
80 | export const SelectFieldSchema = BaseFieldSchema.extend({
81 | type: z.literal('select'),
82 | options: z.array(
83 | z.union([
84 | z.string(),
85 | z.object({
86 | label: z.string(),
87 | value: z.union([z.string(), z.number(), z.boolean()]),
88 | }),
89 | ])
90 | ),
91 | hasMany: z.boolean().optional(),
92 | });
93 |
94 | // Relationship field schema
95 | export const RelationshipFieldSchema = BaseFieldSchema.extend({
96 | type: z.literal('relationship'),
97 | relationTo: z.union([z.string(), z.array(z.string())]),
98 | hasMany: z.boolean().optional(),
99 | filterOptions: z.function().optional(),
100 | maxDepth: z.number().optional(),
101 | });
102 |
103 | // Array field schema
104 | export const ArrayFieldSchema = BaseFieldSchema.extend({
105 | type: z.literal('array'),
106 | minRows: z.number().optional(),
107 | maxRows: z.number().optional(),
108 | fields: z.lazy(() => z.array(FieldSchema)),
109 | });
110 |
111 | // Group field schema
112 | export const GroupFieldSchema = BaseFieldSchema.extend({
113 | type: z.literal('group'),
114 | fields: z.lazy(() => z.array(FieldSchema)),
115 | });
116 |
117 | // Tabs field schema
118 | export const TabsFieldSchema = BaseFieldSchema.extend({
119 | type: z.literal('tabs'),
120 | tabs: z.array(
121 | z.object({
122 | label: z.string(),
123 | name: z.string().optional(),
124 | fields: z.lazy(() => z.array(FieldSchema)),
125 | })
126 | ),
127 | });
128 |
129 | // Rich text field schema
130 | export const RichTextFieldSchema = BaseFieldSchema.extend({
131 | type: z.literal('richText'),
132 | admin: z.object({
133 | elements: z.array(z.string()).optional(),
134 | leaves: z.array(z.string()).optional(),
135 | hideGutter: z.boolean().optional(),
136 | placeholder: z.string().optional(),
137 | }).optional(),
138 | });
139 |
140 | // Union of all field schemas
141 | export const FieldSchema = z.union([
142 | TextFieldSchema,
143 | NumberFieldSchema,
144 | SelectFieldSchema,
145 | RelationshipFieldSchema,
146 | ArrayFieldSchema,
147 | GroupFieldSchema,
148 | TabsFieldSchema,
149 | RichTextFieldSchema,
150 | // Add other field schemas as needed
151 | BaseFieldSchema.extend({ type: z.enum(FieldTypes) }),
152 | ]);
153 |
154 | // Collection schema
155 | export const CollectionSchema = z.object({
156 | slug: z.string().min(1),
157 | labels: z.object({
158 | singular: z.string().optional(),
159 | plural: z.string().optional(),
160 | }).optional(),
161 | admin: z.object({
162 | useAsTitle: z.string().optional(),
163 | defaultColumns: z.array(z.string()).optional(),
164 | listSearchableFields: z.array(z.string()).optional(),
165 | group: z.string().optional(),
166 | description: z.string().optional(),
167 | hideAPIURL: z.boolean().optional(),
168 | disableDuplicate: z.boolean().optional(),
169 | preview: z.function().optional(),
170 | }).optional(),
171 | access: z.object({
172 | read: z.union([z.function(), z.boolean()]).optional(),
173 | create: z.union([z.function(), z.boolean()]).optional(),
174 | update: z.union([z.function(), z.boolean()]).optional(),
175 | delete: z.union([z.function(), z.boolean()]).optional(),
176 | admin: z.union([z.function(), z.boolean()]).optional(),
177 | }).optional(),
178 | fields: z.array(FieldSchema),
179 | hooks: z.object({
180 | beforeOperation: z.function().optional(),
181 | beforeValidate: z.function().optional(),
182 | beforeChange: z.function().optional(),
183 | afterChange: z.function().optional(),
184 | beforeRead: z.function().optional(),
185 | afterRead: z.function().optional(),
186 | beforeDelete: z.function().optional(),
187 | afterDelete: z.function().optional(),
188 | }).optional(),
189 | endpoints: z.array(
190 | z.object({
191 | path: z.string(),
192 | method: z.enum(['get', 'post', 'put', 'patch', 'delete']),
193 | handler: z.function(),
194 | })
195 | ).optional(),
196 | versions: z.object({
197 | drafts: z.boolean().optional(),
198 | max: z.number().optional(),
199 | }).optional(),
200 | timestamps: z.boolean().optional(),
201 | auth: z.boolean().optional(),
202 | upload: z.object({
203 | staticDir: z.string(),
204 | staticURL: z.string(),
205 | mimeTypes: z.array(z.string()).optional(),
206 | filesizeLimit: z.number().optional(),
207 | imageSizes: z.array(
208 | z.object({
209 | name: z.string(),
210 | width: z.number().optional(),
211 | height: z.number().optional(),
212 | crop: z.string().optional(),
213 | })
214 | ).optional(),
215 | }).optional(),
216 | });
217 |
218 | // Global schema
219 | export const GlobalSchema = z.object({
220 | slug: z.string().min(1),
221 | label: z.string().optional(),
222 | admin: z.object({
223 | description: z.string().optional(),
224 | group: z.string().optional(),
225 | }).optional(),
226 | access: z.object({
227 | read: z.union([z.function(), z.boolean()]).optional(),
228 | update: z.union([z.function(), z.boolean()]).optional(),
229 | }).optional(),
230 | fields: z.array(FieldSchema),
231 | hooks: z.object({
232 | beforeValidate: z.function().optional(),
233 | beforeChange: z.function().optional(),
234 | afterChange: z.function().optional(),
235 | beforeRead: z.function().optional(),
236 | afterRead: z.function().optional(),
237 | }).optional(),
238 | versions: z.object({
239 | drafts: z.boolean().optional(),
240 | max: z.number().optional(),
241 | }).optional(),
242 | });
243 |
244 | // Config schema
245 | export const ConfigSchema = z.object({
246 | collections: z.array(CollectionSchema).optional(),
247 | globals: z.array(GlobalSchema).optional(),
248 | admin: z.object({
249 | user: z.string().optional(),
250 | meta: z.object({
251 | titleSuffix: z.string().optional(),
252 | favicon: z.string().optional(),
253 | ogImage: z.string().optional(),
254 | }).optional(),
255 | components: z.record(z.any()).optional(),
256 | css: z.string().optional(),
257 | dateFormat: z.string().optional(),
258 | }).optional(),
259 | serverURL: z.string().optional(),
260 | cors: z.array(z.string()).optional(),
261 | csrf: z.array(z.string()).optional(),
262 | routes: z.object({
263 | admin: z.string().optional(),
264 | api: z.string().optional(),
265 | graphQL: z.string().optional(),
266 | graphQLPlayground: z.string().optional(),
267 | }).optional(),
268 | defaultDepth: z.number().optional(),
269 | maxDepth: z.number().optional(),
270 | rateLimit: z.object({
271 | window: z.number().optional(),
272 | max: z.number().optional(),
273 | trustProxy: z.boolean().optional(),
274 | skip: z.function().optional(),
275 | }).optional(),
276 | upload: z.object({
277 | limits: z.object({
278 | fileSize: z.number().optional(),
279 | }).optional(),
280 | }).optional(),
281 | plugins: z.array(z.any()).optional(),
282 | typescript: z.object({
283 | outputFile: z.string().optional(),
284 | }).optional(),
285 | graphQL: z.object({
286 | schemaOutputFile: z.string().optional(),
287 | disablePlaygroundInProduction: z.boolean().optional(),
288 | }).optional(),
289 | telemetry: z.boolean().optional(),
290 | debug: z.boolean().optional(),
291 | });
--------------------------------------------------------------------------------
/lib/payload/sql.ts:
--------------------------------------------------------------------------------
1 | import { SqlQueryResult } from './types';
2 | import { validationRules } from './validator';
3 |
4 | /**
5 | * Execute a SQL-like query against the validation rules
6 | * @param sql The SQL-like query to execute
7 | * @returns The query results
8 | */
9 | export function executeSqlQuery(sql: string): SqlQueryResult {
10 | // Parse the SQL query
11 | const query = parseQuery(sql);
12 |
13 | // Execute the query
14 | if (query.type === 'SELECT') {
15 | return executeSelectQuery(query);
16 | } else if (query.type === 'DESCRIBE') {
17 | return executeDescribeQuery(query);
18 | } else {
19 | throw new Error(`Unsupported query type: ${query.type}`);
20 | }
21 | }
22 |
23 | /**
24 | * Parse a SQL-like query
25 | * @param sql The SQL-like query to parse
26 | * @returns The parsed query
27 | */
28 | function parseQuery(sql: string): any {
29 | // Simple SQL parser for SELECT and DESCRIBE queries
30 | const trimmedSql = sql.trim();
31 |
32 | if (trimmedSql.toUpperCase().startsWith('SELECT')) {
33 | // Parse SELECT query
34 | const match = trimmedSql.match(/SELECT\s+(.*?)\s+FROM\s+(.*?)(?:\s+WHERE\s+(.*?))?(?:\s+ORDER\s+BY\s+(.*?))?(?:\s+LIMIT\s+(\d+))?$/i);
35 |
36 | if (!match) {
37 | throw new Error('Invalid SELECT query format');
38 | }
39 |
40 | const [, columns, table, whereClause, orderByClause, limitClause] = match;
41 |
42 | return {
43 | type: 'SELECT',
44 | columns: columns.split(',').map(col => col.trim()),
45 | table: table.trim(),
46 | where: whereClause ? parseWhereClause(whereClause) : null,
47 | orderBy: orderByClause ? parseOrderByClause(orderByClause) : null,
48 | limit: limitClause ? parseInt(limitClause, 10) : null,
49 | };
50 | } else if (trimmedSql.toUpperCase().startsWith('DESCRIBE')) {
51 | // Parse DESCRIBE query
52 | const match = trimmedSql.match(/DESCRIBE\s+(.*?)$/i);
53 |
54 | if (!match) {
55 | throw new Error('Invalid DESCRIBE query format');
56 | }
57 |
58 | const [, table] = match;
59 |
60 | return {
61 | type: 'DESCRIBE',
62 | table: table.trim(),
63 | };
64 | } else {
65 | throw new Error('Unsupported query type. Only SELECT and DESCRIBE are supported.');
66 | }
67 | }
68 |
69 | /**
70 | * Parse a WHERE clause
71 | * @param whereClause The WHERE clause to parse
72 | * @returns The parsed WHERE clause
73 | */
74 | function parseWhereClause(whereClause: string): any {
75 | // Simple WHERE clause parser
76 | // This is a simplified implementation that supports basic conditions
77 | const conditions: any[] = [];
78 |
79 | // Split by AND
80 | const andParts = whereClause.split(/\s+AND\s+/i);
81 |
82 | andParts.forEach(part => {
83 | // Check for OR conditions
84 | const orParts = part.split(/\s+OR\s+/i);
85 |
86 | if (orParts.length > 1) {
87 | const orConditions = orParts.map(orPart => parseCondition(orPart));
88 | conditions.push({ type: 'OR', conditions: orConditions });
89 | } else {
90 | conditions.push(parseCondition(part));
91 | }
92 | });
93 |
94 | return conditions.length === 1 ? conditions[0] : { type: 'AND', conditions };
95 | }
96 |
97 | /**
98 | * Parse a single condition
99 | * @param condition The condition to parse
100 | * @returns The parsed condition
101 | */
102 | function parseCondition(condition: string): any {
103 | // Parse a single condition like "column = value"
104 | const match = condition.match(/\s*(.*?)\s*(=|!=|>|<|>=|<=|LIKE|IN)\s*(.*)\s*/i);
105 |
106 | if (!match) {
107 | throw new Error(`Invalid condition format: ${condition}`);
108 | }
109 |
110 | const [, column, operator, value] = match;
111 |
112 | // Handle IN operator
113 | if (operator.toUpperCase() === 'IN') {
114 | const values = value.replace(/^\(|\)$/g, '').split(',').map(v => parseValue(v.trim()));
115 | return { column: column.trim(), operator: 'IN', value: values };
116 | }
117 |
118 | // Handle other operators
119 | return { column: column.trim(), operator, value: parseValue(value.trim()) };
120 | }
121 |
122 | /**
123 | * Parse a value from a condition
124 | * @param value The value to parse
125 | * @returns The parsed value
126 | */
127 | function parseValue(value: string): any {
128 | // Remove quotes from string values
129 | if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"'))) {
130 | return value.substring(1, value.length - 1);
131 | }
132 |
133 | // Parse numbers
134 | if (!isNaN(Number(value))) {
135 | return Number(value);
136 | }
137 |
138 | // Handle boolean values
139 | if (value.toLowerCase() === 'true') return true;
140 | if (value.toLowerCase() === 'false') return false;
141 |
142 | // Handle NULL
143 | if (value.toLowerCase() === 'null') return null;
144 |
145 | // Default to string
146 | return value;
147 | }
148 |
149 | /**
150 | * Parse an ORDER BY clause
151 | * @param orderByClause The ORDER BY clause to parse
152 | * @returns The parsed ORDER BY clause
153 | */
154 | function parseOrderByClause(orderByClause: string): any[] {
155 | // Parse ORDER BY clause
156 | return orderByClause.split(',').map(part => {
157 | const [column, direction] = part.trim().split(/\s+/);
158 | return {
159 | column: column.trim(),
160 | direction: direction && direction.toUpperCase() === 'DESC' ? 'DESC' : 'ASC',
161 | };
162 | });
163 | }
164 |
165 | /**
166 | * Execute a SELECT query
167 | * @param query The parsed SELECT query
168 | * @returns The query results
169 | */
170 | function executeSelectQuery(query: any): SqlQueryResult {
171 | // Get the data source based on the table
172 | let data: any[] = [];
173 |
174 | if (query.table.toLowerCase() === 'validation_rules') {
175 | data = validationRules;
176 | } else {
177 | throw new Error(`Unknown table: ${query.table}`);
178 | }
179 |
180 | // Apply WHERE clause if present
181 | if (query.where) {
182 | data = data.filter(item => evaluateWhereClause(item, query.where));
183 | }
184 |
185 | // Apply ORDER BY if present
186 | if (query.orderBy) {
187 | data = sortData(data, query.orderBy);
188 | }
189 |
190 | // Apply LIMIT if present
191 | if (query.limit !== null) {
192 | data = data.slice(0, query.limit);
193 | }
194 |
195 | // Select columns
196 | const isSelectAll = query.columns.includes('*');
197 | const rows = data.map(item => {
198 | if (isSelectAll) {
199 | return { ...item };
200 | } else {
201 | const row: any = {};
202 | query.columns.forEach((column: string) => {
203 | row[column] = item[column];
204 | });
205 | return row;
206 | }
207 | });
208 |
209 | // Get columns for the result
210 | const columns = isSelectAll && rows.length > 0
211 | ? Object.keys(rows[0])
212 | : query.columns;
213 |
214 | return {
215 | columns,
216 | rows,
217 | };
218 | }
219 |
220 | /**
221 | * Execute a DESCRIBE query
222 | * @param query The parsed DESCRIBE query
223 | * @returns The query results
224 | */
225 | function executeDescribeQuery(query: any): SqlQueryResult {
226 | // Get the data source based on the table
227 | let data: any[] = [];
228 |
229 | if (query.table.toLowerCase() === 'validation_rules') {
230 | // Get a sample rule to extract columns
231 | const sampleRule = validationRules[0];
232 |
233 | if (!sampleRule) {
234 | return { columns: [], rows: [] };
235 | }
236 |
237 | // Create a description of each column
238 | const columns = ['Field', 'Type', 'Description'];
239 | const rows = Object.keys(sampleRule).map(key => {
240 | const value = sampleRule[key];
241 | let type = typeof value;
242 |
243 | if (Array.isArray(value)) {
244 | type = 'object';
245 | } else if (value === null) {
246 | type = 'object';
247 | }
248 |
249 | return {
250 | Field: key,
251 | Type: type,
252 | Description: `Field ${key} of type ${type}`,
253 | };
254 | });
255 |
256 | return { columns, rows };
257 | } else {
258 | throw new Error(`Unknown table: ${query.table}`);
259 | }
260 | }
261 |
262 | /**
263 | * Evaluate a WHERE clause against an item
264 | * @param item The item to evaluate
265 | * @param whereClause The WHERE clause to evaluate
266 | * @returns Whether the item matches the WHERE clause
267 | */
268 | function evaluateWhereClause(item: any, whereClause: any): boolean {
269 | if (whereClause.type === 'AND') {
270 | return whereClause.conditions.every((condition: any) => evaluateWhereClause(item, condition));
271 | } else if (whereClause.type === 'OR') {
272 | return whereClause.conditions.some((condition: any) => evaluateWhereClause(item, condition));
273 | } else {
274 | return evaluateCondition(item, whereClause);
275 | }
276 | }
277 |
278 | /**
279 | * Evaluate a condition against an item
280 | * @param item The item to evaluate
281 | * @param condition The condition to evaluate
282 | * @returns Whether the item matches the condition
283 | */
284 | function evaluateCondition(item: any, condition: any): boolean {
285 | const { column, operator, value } = condition;
286 | const itemValue = item[column];
287 |
288 | switch (operator.toUpperCase()) {
289 | case '=':
290 | return itemValue === value;
291 | case '!=':
292 | return itemValue !== value;
293 | case '>':
294 | return itemValue > value;
295 | case '<':
296 | return itemValue < value;
297 | case '>=':
298 | return itemValue >= value;
299 | case '<=':
300 | return itemValue <= value;
301 | case 'LIKE':
302 | if (typeof itemValue !== 'string') return false;
303 | // Simple LIKE implementation with % as wildcard
304 | const pattern = value.replace(/%/g, '.*');
305 | const regex = new RegExp(`^${pattern}$`, 'i');
306 | return regex.test(itemValue);
307 | case 'IN':
308 | return Array.isArray(value) && value.includes(itemValue);
309 | default:
310 | throw new Error(`Unsupported operator: ${operator}`);
311 | }
312 | }
313 |
314 | /**
315 | * Sort data based on ORDER BY clause
316 | * @param data The data to sort
317 | * @param orderBy The ORDER BY clause
318 | * @returns The sorted data
319 | */
320 | function sortData(data: any[], orderBy: any[]): any[] {
321 | return [...data].sort((a, b) => {
322 | for (const { column, direction } of orderBy) {
323 | if (a[column] < b[column]) return direction === 'ASC' ? -1 : 1;
324 | if (a[column] > b[column]) return direction === 'ASC' ? 1 : -1;
325 | }
326 | return 0;
327 | });
328 | }
--------------------------------------------------------------------------------
/api/server.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { initializeMcpApiHandler } from "../lib/mcp-api-handler";
3 | import {
4 | validatePayloadCode,
5 | queryValidationRules,
6 | executeSqlQuery,
7 | FileType,
8 | generateTemplate,
9 | TemplateType,
10 | scaffoldProject,
11 | validateScaffoldOptions,
12 | ScaffoldOptions
13 | } from "../lib/payload";
14 | import { ensureRedisConnection } from '../lib/redis-connection';
15 |
16 | const handler = initializeMcpApiHandler(
17 | (server) => {
18 | // Echo tool for testing
19 | server.tool("echo", { message: z.string() }, async ({ message }) => ({
20 | content: [{ type: "text", text: `Tool echo: ${message}` }],
21 | }));
22 |
23 | // Validate Payload CMS code
24 | server.tool(
25 | "validate",
26 | {
27 | code: z.string(),
28 | fileType: z.enum(["collection", "field", "global", "config"]),
29 | },
30 | async ({ code, fileType }) => {
31 | const result = validatePayloadCode(code, fileType as FileType);
32 | return {
33 | content: [
34 | {
35 | type: "text",
36 | text: JSON.stringify(result, null, 2),
37 | },
38 | ],
39 | };
40 | }
41 | );
42 |
43 | // Query validation rules
44 | server.tool(
45 | "query",
46 | {
47 | query: z.string(),
48 | fileType: z.enum(["collection", "field", "global", "config"]).optional(),
49 | },
50 | async ({ query, fileType }) => {
51 | const rules = queryValidationRules(query, fileType as FileType | undefined);
52 | return {
53 | content: [
54 | {
55 | type: "text",
56 | text: JSON.stringify({ rules }, null, 2),
57 | },
58 | ],
59 | };
60 | }
61 | );
62 |
63 | // Execute SQL-like query
64 | server.tool(
65 | "mcp_query",
66 | {
67 | sql: z.string(),
68 | },
69 | async ({ sql }) => {
70 | try {
71 | const results = executeSqlQuery(sql);
72 | return {
73 | content: [
74 | {
75 | type: "text",
76 | text: JSON.stringify({ results }, null, 2),
77 | },
78 | ],
79 | };
80 | } catch (error) {
81 | return {
82 | content: [
83 | {
84 | type: "text",
85 | text: JSON.stringify({ error: (error as Error).message }, null, 2),
86 | },
87 | ],
88 | };
89 | }
90 | }
91 | );
92 |
93 | // Generate Payload CMS 3 code templates
94 | server.tool(
95 | "generate_template",
96 | {
97 | templateType: z.enum([
98 | "collection",
99 | "field",
100 | "global",
101 | "config",
102 | "access-control",
103 | "hook",
104 | "endpoint",
105 | "plugin",
106 | "block",
107 | "migration"
108 | ]),
109 | options: z.record(z.any()),
110 | },
111 | async ({ templateType, options }) => {
112 | try {
113 | const code = generateTemplate(templateType as TemplateType, options);
114 | return {
115 | content: [
116 | {
117 | type: "text",
118 | text: code,
119 | },
120 | ],
121 | };
122 | } catch (error) {
123 | return {
124 | content: [
125 | {
126 | type: "text",
127 | text: JSON.stringify({ error: (error as Error).message }, null, 2),
128 | },
129 | ],
130 | };
131 | }
132 | }
133 | );
134 |
135 | // Generate a complete Payload CMS 3 collection
136 | server.tool(
137 | "generate_collection",
138 | {
139 | slug: z.string(),
140 | fields: z.array(
141 | z.object({
142 | name: z.string(),
143 | type: z.string(),
144 | required: z.boolean().optional(),
145 | unique: z.boolean().optional(),
146 | })
147 | ).optional(),
148 | auth: z.boolean().optional(),
149 | timestamps: z.boolean().optional(),
150 | admin: z.object({
151 | useAsTitle: z.string().optional(),
152 | defaultColumns: z.array(z.string()).optional(),
153 | group: z.string().optional(),
154 | }).optional(),
155 | hooks: z.boolean().optional(),
156 | access: z.boolean().optional(),
157 | versions: z.boolean().optional(),
158 | },
159 | async (options) => {
160 | try {
161 | const code = generateTemplate('collection', options);
162 | return {
163 | content: [
164 | {
165 | type: "text",
166 | text: code,
167 | },
168 | ],
169 | };
170 | } catch (error) {
171 | return {
172 | content: [
173 | {
174 | type: "text",
175 | text: JSON.stringify({ error: (error as Error).message }, null, 2),
176 | },
177 | ],
178 | };
179 | }
180 | }
181 | );
182 |
183 | // Generate a Payload CMS 3 field
184 | server.tool(
185 | "generate_field",
186 | {
187 | name: z.string(),
188 | type: z.string(),
189 | required: z.boolean().optional(),
190 | unique: z.boolean().optional(),
191 | localized: z.boolean().optional(),
192 | access: z.boolean().optional(),
193 | admin: z.object({
194 | description: z.string().optional(),
195 | readOnly: z.boolean().optional(),
196 | }).optional(),
197 | validation: z.boolean().optional(),
198 | defaultValue: z.any().optional(),
199 | },
200 | async (options) => {
201 | try {
202 | const code = generateTemplate('field', options);
203 | return {
204 | content: [
205 | {
206 | type: "text",
207 | text: code,
208 | },
209 | ],
210 | };
211 | } catch (error) {
212 | return {
213 | content: [
214 | {
215 | type: "text",
216 | text: JSON.stringify({ error: (error as Error).message }, null, 2),
217 | },
218 | ],
219 | };
220 | }
221 | }
222 | );
223 |
224 | // Scaffold a complete Payload CMS 3 project
225 | server.tool(
226 | "scaffold_project",
227 | {
228 | projectName: z.string(),
229 | description: z.string().optional(),
230 | serverUrl: z.string().optional(),
231 | database: z.enum(['mongodb', 'postgres']).optional(),
232 | auth: z.boolean().optional(),
233 | admin: z.object({
234 | user: z.string().optional(),
235 | bundler: z.enum(['webpack', 'vite']).optional(),
236 | }).optional(),
237 | collections: z.array(
238 | z.object({
239 | name: z.string(),
240 | fields: z.array(
241 | z.object({
242 | name: z.string(),
243 | type: z.string(),
244 | required: z.boolean().optional(),
245 | unique: z.boolean().optional(),
246 | })
247 | ).optional(),
248 | auth: z.boolean().optional(),
249 | timestamps: z.boolean().optional(),
250 | admin: z.object({
251 | useAsTitle: z.string().optional(),
252 | group: z.string().optional(),
253 | }).optional(),
254 | versions: z.boolean().optional(),
255 | })
256 | ).optional(),
257 | globals: z.array(
258 | z.object({
259 | name: z.string(),
260 | fields: z.array(
261 | z.object({
262 | name: z.string(),
263 | type: z.string(),
264 | })
265 | ).optional(),
266 | versions: z.boolean().optional(),
267 | })
268 | ).optional(),
269 | blocks: z.array(
270 | z.object({
271 | name: z.string(),
272 | fields: z.array(
273 | z.object({
274 | name: z.string(),
275 | type: z.string(),
276 | })
277 | ).optional(),
278 | imageField: z.boolean().optional(),
279 | contentField: z.boolean().optional(),
280 | })
281 | ).optional(),
282 | plugins: z.array(z.string()).optional(),
283 | typescript: z.boolean().optional(),
284 | },
285 | async (options) => {
286 | try {
287 | // Validate options
288 | const validation = validateScaffoldOptions(options);
289 | if (!validation.isValid) {
290 | return {
291 | content: [
292 | {
293 | type: "text",
294 | text: JSON.stringify({
295 | error: "Invalid scaffold options",
296 | details: validation.errors
297 | }, null, 2),
298 | },
299 | ],
300 | };
301 | }
302 |
303 | // Generate project scaffold
304 | const fileStructure = scaffoldProject(options as ScaffoldOptions);
305 |
306 | return {
307 | content: [
308 | {
309 | type: "text",
310 | text: JSON.stringify({
311 | message: `Successfully scaffolded Payload CMS 3 project: ${options.projectName}`,
312 | fileStructure
313 | }, null, 2),
314 | },
315 | ],
316 | };
317 | } catch (error) {
318 | return {
319 | content: [
320 | {
321 | type: "text",
322 | text: JSON.stringify({ error: (error as Error).message }, null, 2),
323 | },
324 | ],
325 | };
326 | }
327 | }
328 | );
329 | },
330 | {
331 | capabilities: {
332 | tools: {
333 | echo: {
334 | description: "Echo a message",
335 | },
336 | validate: {
337 | description: "Validate Payload CMS code",
338 | },
339 | query: {
340 | description: "Query validation rules for Payload CMS",
341 | },
342 | mcp_query: {
343 | description: "Execute SQL-like query against validation rules",
344 | },
345 | generate_template: {
346 | description: "Generate Payload CMS 3 code templates",
347 | },
348 | generate_collection: {
349 | description: "Generate a complete Payload CMS 3 collection",
350 | },
351 | generate_field: {
352 | description: "Generate a Payload CMS 3 field",
353 | },
354 | scaffold_project: {
355 | description: "Scaffold a complete Payload CMS 3 project structure",
356 | },
357 | },
358 | },
359 | }
360 | );
361 |
362 | // Ensure Redis connection is established before handling requests
363 | ensureRedisConnection().catch(error => {
364 | console.error("Failed to ensure Redis connection in server.ts:", error);
365 | });
366 |
367 | export default handler;
368 |
--------------------------------------------------------------------------------
/lib/payload/scaffolder.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 | import { generateTemplate } from './generator';
3 |
4 | /**
5 | * Options for scaffolding a Payload CMS 3 project
6 | */
7 | export interface ScaffoldOptions {
8 | projectName: string;
9 | description?: string;
10 | serverUrl?: string;
11 | database?: 'mongodb' | 'postgres';
12 | auth?: boolean;
13 | admin?: {
14 | user?: string;
15 | bundler?: 'webpack' | 'vite';
16 | };
17 | collections?: {
18 | name: string;
19 | fields?: {
20 | name: string;
21 | type: string;
22 | required?: boolean;
23 | unique?: boolean;
24 | }[];
25 | auth?: boolean;
26 | timestamps?: boolean;
27 | admin?: {
28 | useAsTitle?: string;
29 | group?: string;
30 | };
31 | versions?: boolean;
32 | }[];
33 | globals?: {
34 | name: string;
35 | fields?: {
36 | name: string;
37 | type: string;
38 | }[];
39 | versions?: boolean;
40 | }[];
41 | blocks?: {
42 | name: string;
43 | fields?: {
44 | name: string;
45 | type: string;
46 | }[];
47 | imageField?: boolean;
48 | contentField?: boolean;
49 | }[];
50 | plugins?: string[];
51 | typescript?: boolean;
52 | }
53 |
54 | /**
55 | * Scaffold file structure for a Payload CMS 3 project
56 | */
57 | export interface ScaffoldFileStructure {
58 | [path: string]: string | ScaffoldFileStructure;
59 | }
60 |
61 | /**
62 | * Scaffolds a Payload CMS 3 project
63 | */
64 | export const scaffoldProject = (options: ScaffoldOptions): ScaffoldFileStructure => {
65 | const {
66 | projectName,
67 | description = `A Payload CMS 3 project`,
68 | serverUrl = 'http://localhost:3000',
69 | database = 'mongodb',
70 | auth = true,
71 | admin = {},
72 | collections = [],
73 | globals = [],
74 | blocks = [],
75 | plugins = [],
76 | typescript = true,
77 | } = options;
78 |
79 | // Create the file structure
80 | const fileStructure: ScaffoldFileStructure = {
81 | // Root files
82 | 'package.json': generatePackageJson(projectName, description, database, typescript, plugins),
83 | 'tsconfig.json': generateTsConfig(),
84 | '.env': generateEnvFile(database),
85 | '.env.example': generateEnvFile(database),
86 | '.gitignore': generateGitignore(),
87 | 'README.md': generateReadme(projectName, description),
88 |
89 | // Source directory
90 | 'src': {
91 | // Config
92 | 'payload.config.ts': generatePayloadConfig(projectName, serverUrl, database, admin, typescript),
93 |
94 | // Collections
95 | 'collections': collections.reduce((acc, collection) => {
96 | acc[`${collection.name}.ts`] = generateTemplate('collection', {
97 | slug: collection.name,
98 | fields: collection.fields || [],
99 | auth: collection.auth,
100 | timestamps: collection.timestamps !== false, // Default to true
101 | admin: collection.admin,
102 | versions: collection.versions,
103 | access: true, // Always include access control
104 | hooks: true, // Always include hooks
105 | });
106 | return acc;
107 | }, {} as ScaffoldFileStructure),
108 |
109 | // Globals
110 | 'globals': globals.reduce((acc, global) => {
111 | acc[`${global.name}.ts`] = generateTemplate('global', {
112 | slug: global.name,
113 | fields: global.fields || [],
114 | versions: global.versions,
115 | access: true, // Always include access control
116 | });
117 | return acc;
118 | }, {} as ScaffoldFileStructure),
119 |
120 | // Blocks
121 | 'blocks': blocks.reduce((acc, block) => {
122 | acc[`${block.name}.ts`] = generateTemplate('block', {
123 | slug: block.name,
124 | fields: block.fields || [],
125 | imageField: block.imageField,
126 | contentField: block.contentField,
127 | });
128 | return acc;
129 | }, {} as ScaffoldFileStructure),
130 |
131 | // Access control
132 | 'access': {
133 | 'index.ts': generateAccessIndex(),
134 | },
135 |
136 | // Hooks
137 | 'hooks': {
138 | 'index.ts': generateHooksIndex(),
139 | },
140 |
141 | // Endpoints
142 | 'endpoints': {
143 | 'index.ts': generateEndpointsIndex(),
144 | },
145 |
146 | // Server
147 | 'server.ts': generateServer(),
148 | },
149 | };
150 |
151 | return fileStructure;
152 | };
153 |
154 | /**
155 | * Generates a package.json file
156 | */
157 | const generatePackageJson = (
158 | projectName: string,
159 | description: string,
160 | database: 'mongodb' | 'postgres',
161 | typescript: boolean,
162 | plugins: string[]
163 | ): string => {
164 | const dbDependency = database === 'mongodb'
165 | ? `"@payloadcms/db-mongodb": "^1.0.0",`
166 | : `"@payloadcms/db-postgres": "^1.0.0",`;
167 |
168 | const pluginDependencies = plugins.map(plugin => {
169 | switch (plugin) {
170 | case 'seo':
171 | return `"@payloadcms/plugin-seo": "^1.0.0",`;
172 | case 'nested-docs':
173 | return `"@payloadcms/plugin-nested-docs": "^1.0.0",`;
174 | case 'form-builder':
175 | return `"@payloadcms/plugin-form-builder": "^1.0.0",`;
176 | case 'cloud':
177 | return `"@payloadcms/plugin-cloud": "^1.0.0",`;
178 | default:
179 | return '';
180 | }
181 | }).filter(Boolean).join('\n ');
182 |
183 | return `{
184 | "name": "${projectName.toLowerCase().replace(/[^a-z0-9]/g, '-')}",
185 | "description": "${description}",
186 | "version": "1.0.0",
187 | "main": "dist/server.js",
188 | "license": "MIT",
189 | "scripts": {
190 | "dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon",
191 | "build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
192 | "build:server": "${typescript ? 'tsc' : 'copyfiles src/* dist/'}",
193 | "build": "yarn build:payload && yarn build:server",
194 | "start": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
195 | "generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types",
196 | "generate:graphQLSchema": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema"
197 | },
198 | "dependencies": {
199 | "payload": "^2.0.0",
200 | ${dbDependency}
201 | "@payloadcms/richtext-lexical": "^1.0.0",
202 | ${pluginDependencies}
203 | "dotenv": "^16.0.0",
204 | "express": "^4.17.1"
205 | },
206 | "devDependencies": {
207 | ${typescript ? `
208 | "typescript": "^5.0.0",
209 | "@types/express": "^4.17.9",
210 | ` : ''}
211 | "cross-env": "^7.0.3",
212 | "nodemon": "^2.0.6",
213 | ${typescript ? '' : '"copyfiles": "^2.4.1",'}
214 | "payload-types": "file:src/payload-types.ts"
215 | }
216 | }`;
217 | };
218 |
219 | /**
220 | * Generates a tsconfig.json file
221 | */
222 | const generateTsConfig = (): string => {
223 | return `{
224 | "compilerOptions": {
225 | "target": "es2020",
226 | "module": "commonjs",
227 | "moduleResolution": "node",
228 | "esModuleInterop": true,
229 | "strict": true,
230 | "outDir": "dist",
231 | "rootDir": "src",
232 | "skipLibCheck": true,
233 | "sourceMap": true,
234 | "declaration": true,
235 | "jsx": "react",
236 | "baseUrl": ".",
237 | "paths": {
238 | "payload/generated-types": ["src/payload-types.ts"]
239 | }
240 | },
241 | "include": ["src"],
242 | "exclude": ["node_modules", "dist"]
243 | }`;
244 | };
245 |
246 | /**
247 | * Generates an .env file
248 | */
249 | const generateEnvFile = (database: 'mongodb' | 'postgres'): string => {
250 | return `# Server
251 | PORT=3000
252 | NODE_ENV=development
253 |
254 | # Database
255 | ${database === 'mongodb'
256 | ? 'MONGODB_URI=mongodb://localhost:27017/payload-cms-3-project'
257 | : 'DATABASE_URI=postgres://postgres:postgres@localhost:5432/payload-cms-3-project'}
258 |
259 | # Payload
260 | PAYLOAD_SECRET=your-payload-secret-key-here
261 | PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000`;
262 | };
263 |
264 | /**
265 | * Generates a .gitignore file
266 | */
267 | const generateGitignore = (): string => {
268 | return `# dependencies
269 | /node_modules
270 |
271 | # build
272 | /dist
273 | /build
274 |
275 | # misc
276 | .DS_Store
277 | .env
278 | .env.local
279 | .env.development.local
280 | .env.test.local
281 | .env.production.local
282 |
283 | # logs
284 | npm-debug.log*
285 | yarn-debug.log*
286 | yarn-error.log*
287 |
288 | # payload
289 | /src/payload-types.ts`;
290 | };
291 |
292 | /**
293 | * Generates a README.md file
294 | */
295 | const generateReadme = (projectName: string, description: string): string => {
296 | return `# ${projectName}
297 |
298 | ${description}
299 |
300 | ## Getting Started
301 |
302 | ### Development
303 |
304 | 1. Clone this repository
305 | 2. Install dependencies with \`yarn\` or \`npm install\`
306 | 3. Copy \`.env.example\` to \`.env\` and configure your environment variables
307 | 4. Start the development server with \`yarn dev\` or \`npm run dev\`
308 | 5. Visit http://localhost:3000/admin to access the admin panel
309 |
310 | ### Production
311 |
312 | 1. Build the project with \`yarn build\` or \`npm run build\`
313 | 2. Start the production server with \`yarn start\` or \`npm start\`
314 |
315 | ## Features
316 |
317 | - Payload CMS 3.0
318 | - TypeScript
319 | - Express server
320 | - Admin panel
321 | - API endpoints
322 | - GraphQL API
323 |
324 | ## Project Structure
325 |
326 | - \`/src\` - Source code
327 | - \`/collections\` - Collection definitions
328 | - \`/globals\` - Global definitions
329 | - \`/blocks\` - Block definitions
330 | - \`/access\` - Access control functions
331 | - \`/hooks\` - Hook functions
332 | - \`/endpoints\` - Custom API endpoints
333 | - \`payload.config.ts\` - Payload configuration
334 | - \`server.ts\` - Express server
335 |
336 | ## License
337 |
338 | MIT`;
339 | };
340 |
341 | /**
342 | * Generates a payload.config.ts file
343 | */
344 | const generatePayloadConfig = (
345 | projectName: string,
346 | serverUrl: string,
347 | database: 'mongodb' | 'postgres',
348 | admin: any,
349 | typescript: boolean
350 | ): string => {
351 | return generateTemplate('config', {
352 | projectName,
353 | serverUrl,
354 | admin,
355 | db: database,
356 | typescript,
357 | csrf: true,
358 | rateLimit: true,
359 | });
360 | };
361 |
362 | /**
363 | * Generates an access/index.ts file
364 | */
365 | const generateAccessIndex = (): string => {
366 | return `// Export all access control functions
367 | export * from './isAdmin';
368 | export * from './isAdminOrEditor';
369 | export * from './isAdminOrSelf';
370 |
371 | // Example access control function for admin users
372 | export const isAdmin = ({ req }) => {
373 | return req.user?.role === 'admin';
374 | };
375 |
376 | // Example access control function for admin or editor users
377 | export const isAdminOrEditor = ({ req }) => {
378 | return ['admin', 'editor'].includes(req.user?.role);
379 | };
380 |
381 | // Example access control function for admin users or the user themselves
382 | export const isAdminOrSelf = ({ req }) => {
383 | const { user } = req;
384 |
385 | if (!user) return false;
386 | if (user.role === 'admin') return true;
387 |
388 | // If there's an ID in the URL, check if it matches the user's ID
389 | const id = req.params?.id;
390 | if (id && user.id === id) return true;
391 |
392 | return false;
393 | };`;
394 | };
395 |
396 | /**
397 | * Generates a hooks/index.ts file
398 | */
399 | const generateHooksIndex = (): string => {
400 | return `// Export all hook functions
401 | export * from './populateCreatedBy';
402 | export * from './formatSlug';
403 |
404 | // Example hook to populate createdBy field
405 | export const populateCreatedBy = ({ req }) => {
406 | return {
407 | createdBy: req.user?.id,
408 | };
409 | };
410 |
411 | // Example hook to format a slug
412 | export const formatSlug = ({ value }) => {
413 | if (!value) return '';
414 |
415 | return value
416 | .toLowerCase()
417 | .replace(/ /g, '-')
418 | .replace(/[^\\w-]+/g, '');
419 | };`;
420 | };
421 |
422 | /**
423 | * Generates an endpoints/index.ts file
424 | */
425 | const generateEndpointsIndex = (): string => {
426 | return `import { Payload } from 'payload';
427 | import { Request, Response } from 'express';
428 |
429 | // Register all custom endpoints
430 | export const registerEndpoints = (payload: Payload): void => {
431 | // Example health check endpoint
432 | payload.router.get('/api/health', (req: Request, res: Response) => {
433 | res.status(200).json({
434 | status: 'ok',
435 | message: 'API is healthy',
436 | timestamp: new Date().toISOString(),
437 | });
438 | });
439 |
440 | // Example custom data endpoint
441 | payload.router.get('/api/custom-data', async (req: Request, res: Response) => {
442 | try {
443 | // Example: Get data from a collection
444 | // const result = await payload.find({
445 | // collection: 'your-collection',
446 | // limit: 10,
447 | // });
448 |
449 | res.status(200).json({
450 | message: 'Custom data endpoint',
451 | // data: result.docs,
452 | });
453 | } catch (error) {
454 | res.status(500).json({
455 | message: 'Error fetching data',
456 | error: error.message,
457 | });
458 | }
459 | });
460 | };`;
461 | };
462 |
463 | /**
464 | * Generates a server.ts file
465 | */
466 | const generateServer = (): string => {
467 | return `import express from 'express';
468 | import payload from 'payload';
469 | import { registerEndpoints } from './endpoints';
470 | import path from 'path';
471 |
472 | // Load environment variables
473 | require('dotenv').config();
474 |
475 | // Create an Express app
476 | const app = express();
477 |
478 | // Redirect root to Admin panel
479 | app.get('/', (_, res) => {
480 | res.redirect('/admin');
481 | });
482 |
483 | // Initialize Payload
484 | const start = async () => {
485 | await payload.init({
486 | secret: process.env.PAYLOAD_SECRET || 'your-payload-secret-key-here',
487 | express: app,
488 | onInit: () => {
489 | payload.logger.info(\`Payload Admin URL: \${payload.getAdminURL()}\`);
490 | },
491 | });
492 |
493 | // Register custom endpoints
494 | registerEndpoints(payload);
495 |
496 | // Add your own express routes here
497 | app.get('/api/custom-route', (req, res) => {
498 | res.json({ message: 'Custom route' });
499 | });
500 |
501 | // Serve static files from the 'public' directory
502 | app.use('/public', express.static(path.resolve(__dirname, '../public')));
503 |
504 | // Start the server
505 | const PORT = process.env.PORT || 3000;
506 | app.listen(PORT, () => {
507 | payload.logger.info(\`Server started on port \${PORT}\`);
508 | });
509 | };
510 |
511 | start();`;
512 | };
513 |
514 | /**
515 | * Validates scaffold options
516 | */
517 | export const validateScaffoldOptions = (options: any): { isValid: boolean; errors?: string[] } => {
518 | try {
519 | const schema = z.object({
520 | projectName: z.string().min(1, "Project name is required"),
521 | description: z.string().optional(),
522 | serverUrl: z.string().url("Server URL must be a valid URL").optional(),
523 | database: z.enum(['mongodb', 'postgres']).optional(),
524 | auth: z.boolean().optional(),
525 | admin: z.object({
526 | user: z.string().optional(),
527 | bundler: z.enum(['webpack', 'vite']).optional(),
528 | }).optional(),
529 | collections: z.array(
530 | z.object({
531 | name: z.string().min(1, "Collection name is required"),
532 | fields: z.array(
533 | z.object({
534 | name: z.string().min(1, "Field name is required"),
535 | type: z.string().min(1, "Field type is required"),
536 | required: z.boolean().optional(),
537 | unique: z.boolean().optional(),
538 | })
539 | ).optional(),
540 | auth: z.boolean().optional(),
541 | timestamps: z.boolean().optional(),
542 | admin: z.object({
543 | useAsTitle: z.string().optional(),
544 | group: z.string().optional(),
545 | }).optional(),
546 | versions: z.boolean().optional(),
547 | })
548 | ).optional(),
549 | globals: z.array(
550 | z.object({
551 | name: z.string().min(1, "Global name is required"),
552 | fields: z.array(
553 | z.object({
554 | name: z.string().min(1, "Field name is required"),
555 | type: z.string().min(1, "Field type is required"),
556 | })
557 | ).optional(),
558 | versions: z.boolean().optional(),
559 | })
560 | ).optional(),
561 | blocks: z.array(
562 | z.object({
563 | name: z.string().min(1, "Block name is required"),
564 | fields: z.array(
565 | z.object({
566 | name: z.string().min(1, "Field name is required"),
567 | type: z.string().min(1, "Field type is required"),
568 | })
569 | ).optional(),
570 | imageField: z.boolean().optional(),
571 | contentField: z.boolean().optional(),
572 | })
573 | ).optional(),
574 | plugins: z.array(z.string()).optional(),
575 | typescript: z.boolean().optional(),
576 | });
577 |
578 | schema.parse(options);
579 | return { isValid: true };
580 | } catch (error) {
581 | if (error instanceof z.ZodError) {
582 | return {
583 | isValid: false,
584 | errors: error.errors.map(e => `${e.path.join('.')}: ${e.message}`),
585 | };
586 | }
587 |
588 | return {
589 | isValid: false,
590 | errors: [(error as Error).message],
591 | };
592 | }
593 | };
--------------------------------------------------------------------------------
/lib/payload/validator.ts:
--------------------------------------------------------------------------------
1 | import { CollectionSchema, FieldSchema, GlobalSchema, ConfigSchema } from './schemas';
2 | import { z } from 'zod';
3 | import { ValidationRule } from './types';
4 |
5 | export type ValidationResult = {
6 | isValid: boolean;
7 | errors?: string[];
8 | warnings?: string[];
9 | suggestions?: {
10 | message: string;
11 | code?: string;
12 | }[];
13 | references?: {
14 | title: string;
15 | url: string;
16 | }[];
17 | };
18 |
19 | export type FileType = 'collection' | 'field' | 'global' | 'config';
20 |
21 | // Define validation rules that can be queried
22 | export const validationRules: ValidationRule[] = [
23 | {
24 | id: 'naming-conventions',
25 | name: 'Naming Conventions',
26 | description: 'Names should follow consistent conventions (camelCase or snake_case)',
27 | category: 'best-practices',
28 | fileTypes: ['collection', 'field', 'global', 'config'],
29 | examples: {
30 | valid: ['myField', 'my_field'],
31 | invalid: ['my field', 'my-field', 'my_Field']
32 | }
33 | },
34 | {
35 | id: 'reserved-words',
36 | name: 'Reserved Words',
37 | description: 'Avoid using JavaScript reserved words for names',
38 | category: 'best-practices',
39 | fileTypes: ['collection', 'field', 'global', 'config'],
40 | examples: {
41 | valid: ['title', 'content', 'author'],
42 | invalid: ['constructor', 'prototype', '__proto__']
43 | }
44 | },
45 | {
46 | id: 'access-control',
47 | name: 'Access Control',
48 | description: 'Define access control for collections and fields',
49 | category: 'security',
50 | fileTypes: ['collection', 'field', 'global'],
51 | examples: {
52 | valid: ['access: { read: () => true, update: () => true }'],
53 | invalid: ['// No access control defined']
54 | }
55 | },
56 | {
57 | id: 'sensitive-fields',
58 | name: 'Sensitive Fields Protection',
59 | description: 'Sensitive fields should have explicit read access control',
60 | category: 'security',
61 | fileTypes: ['field'],
62 | examples: {
63 | valid: ['{ name: "password", type: "text", access: { read: () => false } }'],
64 | invalid: ['{ name: "password", type: "text" }']
65 | }
66 | },
67 | {
68 | id: 'indexed-fields',
69 | name: 'Indexed Fields',
70 | description: 'Fields used for searching or filtering should be indexed',
71 | category: 'performance',
72 | fileTypes: ['field'],
73 | examples: {
74 | valid: ['{ name: "email", type: "email", index: true }'],
75 | invalid: ['{ name: "email", type: "email" }']
76 | }
77 | },
78 | {
79 | id: 'relationship-depth',
80 | name: 'Relationship Depth',
81 | description: 'Relationship fields should have a maxDepth to prevent deep queries',
82 | category: 'performance',
83 | fileTypes: ['field'],
84 | examples: {
85 | valid: ['{ type: "relationship", relationTo: "posts", maxDepth: 1 }'],
86 | invalid: ['{ type: "relationship", relationTo: "posts" }']
87 | }
88 | },
89 | {
90 | id: 'field-validation',
91 | name: 'Field Validation',
92 | description: 'Required fields should have validation',
93 | category: 'data-integrity',
94 | fileTypes: ['field'],
95 | examples: {
96 | valid: ['{ name: "title", type: "text", required: true, validate: (value) => value ? true : "Required" }'],
97 | invalid: ['{ name: "title", type: "text", required: true }']
98 | }
99 | },
100 | {
101 | id: 'timestamps',
102 | name: 'Timestamps',
103 | description: 'Collections should have timestamps enabled',
104 | category: 'best-practices',
105 | fileTypes: ['collection'],
106 | examples: {
107 | valid: ['{ slug: "posts", timestamps: true }'],
108 | invalid: ['{ slug: "posts" }']
109 | }
110 | },
111 | {
112 | id: 'admin-ui',
113 | name: 'Admin UI Configuration',
114 | description: 'Collections should specify which field to use as title in admin UI',
115 | category: 'usability',
116 | fileTypes: ['collection'],
117 | examples: {
118 | valid: ['{ admin: { useAsTitle: "title" } }'],
119 | invalid: ['{ admin: {} }']
120 | }
121 | }
122 | ];
123 |
124 | // Common validation rules
125 | const commonValidationRules = {
126 | namingConventions: (name: string): string[] => {
127 | const errors: string[] = [];
128 | if (name.includes(' ')) {
129 | errors.push(`Name "${name}" should not contain spaces. Use camelCase or snake_case instead.`);
130 | }
131 | if (name.match(/[A-Z]/) && name.match(/_/)) {
132 | errors.push(`Name "${name}" mixes camelCase and snake_case. Choose one convention.`);
133 | }
134 | return errors;
135 | },
136 |
137 | reservedWords: (name: string): string[] => {
138 | const reserved = ['constructor', 'prototype', '__proto__', 'toString', 'toJSON', 'valueOf'];
139 | return reserved.includes(name)
140 | ? [`Name "${name}" is a reserved JavaScript word and should be avoided.`]
141 | : [];
142 | }
143 | };
144 |
145 | // Security validation rules
146 | const securityValidationRules = {
147 | accessControl: (obj: any): string[] => {
148 | const warnings: string[] = [];
149 | if (!obj.access) {
150 | warnings.push('No access control defined. This might expose data to unauthorized users.');
151 | }
152 | return warnings;
153 | },
154 |
155 | authFields: (fields: any[]): string[] => {
156 | const warnings: string[] = [];
157 | const sensitiveFields = fields.filter(f =>
158 | f.name?.toLowerCase().includes('password') ||
159 | f.name?.toLowerCase().includes('token') ||
160 | f.name?.toLowerCase().includes('secret')
161 | );
162 |
163 | for (const field of sensitiveFields) {
164 | if (!field.access || !field.access.read) {
165 | warnings.push(`Sensitive field "${field.name}" should have explicit read access control.`);
166 | }
167 | }
168 |
169 | return warnings;
170 | }
171 | };
172 |
173 | // Performance validation rules
174 | const performanceValidationRules = {
175 | indexedFields: (fields: any[]): string[] => {
176 | const warnings: string[] = [];
177 | const searchableFields = fields.filter(f =>
178 | f.type === 'text' ||
179 | f.type === 'email' ||
180 | f.type === 'textarea'
181 | );
182 |
183 | for (const field of searchableFields) {
184 | if (field.unique && !field.index) {
185 | warnings.push(`Field "${field.name}" is unique but not indexed. Consider adding 'index: true' for better performance.`);
186 | }
187 | }
188 |
189 | return warnings;
190 | }
191 | };
192 |
193 | /**
194 | * Validates a Payload CMS collection
195 | */
196 | export const validateCollection = (code: string): ValidationResult => {
197 | try {
198 | // Parse the code to get a JavaScript object
199 | // This is a simplified approach - in a real implementation, you'd need to safely evaluate the code
200 | const collection = eval(`(${code})`);
201 |
202 | // Validate against schema
203 | CollectionSchema.parse(collection);
204 |
205 | const errors: string[] = [];
206 | const warnings: string[] = [];
207 | const suggestions: { message: string; code?: string }[] = [];
208 |
209 | // Check naming conventions
210 | if (collection.slug) {
211 | errors.push(...commonValidationRules.namingConventions(collection.slug));
212 | errors.push(...commonValidationRules.reservedWords(collection.slug));
213 | }
214 |
215 | // Check fields
216 | if (collection.fields) {
217 | for (const field of collection.fields) {
218 | if (field.name) {
219 | errors.push(...commonValidationRules.namingConventions(field.name));
220 | errors.push(...commonValidationRules.reservedWords(field.name));
221 | }
222 | }
223 |
224 | // Security checks
225 | warnings.push(...securityValidationRules.accessControl(collection));
226 | warnings.push(...securityValidationRules.authFields(collection.fields));
227 |
228 | // Performance checks
229 | warnings.push(...performanceValidationRules.indexedFields(collection.fields));
230 | }
231 |
232 | // Add suggestions
233 | if (!collection.admin?.useAsTitle) {
234 | suggestions.push({
235 | message: "Consider adding 'useAsTitle' to specify which field to use as the title in the admin UI.",
236 | code: `admin: { useAsTitle: 'title' }`
237 | });
238 | }
239 |
240 | if (!collection.timestamps) {
241 | suggestions.push({
242 | message: "Consider enabling timestamps to automatically track creation and update times.",
243 | code: `timestamps: true`
244 | });
245 | }
246 |
247 | return {
248 | isValid: errors.length === 0,
249 | errors: errors.length > 0 ? errors : undefined,
250 | warnings: warnings.length > 0 ? warnings : undefined,
251 | suggestions: suggestions.length > 0 ? suggestions : undefined,
252 | references: [
253 | {
254 | title: "Payload CMS Collections Documentation",
255 | url: "https://payloadcms.com/docs/configuration/collections"
256 | }
257 | ]
258 | };
259 | } catch (error) {
260 | if (error instanceof z.ZodError) {
261 | return {
262 | isValid: false,
263 | errors: error.errors.map(e => `${e.path.join('.')}: ${e.message}`),
264 | references: [
265 | {
266 | title: "Payload CMS Collections Documentation",
267 | url: "https://payloadcms.com/docs/configuration/collections"
268 | }
269 | ]
270 | };
271 | }
272 |
273 | return {
274 | isValid: false,
275 | errors: [(error as Error).message],
276 | references: [
277 | {
278 | title: "Payload CMS Collections Documentation",
279 | url: "https://payloadcms.com/docs/configuration/collections"
280 | }
281 | ]
282 | };
283 | }
284 | };
285 |
286 | /**
287 | * Validates a Payload CMS field
288 | */
289 | export const validateField = (code: string): ValidationResult => {
290 | try {
291 | // Parse the code to get a JavaScript object
292 | const field = eval(`(${code})`);
293 |
294 | // Validate against schema
295 | FieldSchema.parse(field);
296 |
297 | const errors: string[] = [];
298 | const warnings: string[] = [];
299 | const suggestions: { message: string; code?: string }[] = [];
300 |
301 | // Check naming conventions
302 | if (field.name) {
303 | errors.push(...commonValidationRules.namingConventions(field.name));
304 | errors.push(...commonValidationRules.reservedWords(field.name));
305 | }
306 |
307 | // Field-specific validations
308 | if (field.type === 'relationship' && !field.maxDepth) {
309 | warnings.push("Relationship field without maxDepth could lead to deep queries. Consider adding a maxDepth limit.");
310 | suggestions.push({
311 | message: "Add maxDepth to limit relationship depth",
312 | code: `maxDepth: 1`
313 | });
314 | }
315 |
316 | if (field.type === 'text' && field.required && !field.validate) {
317 | suggestions.push({
318 | message: "Consider adding validation for required text fields",
319 | code: `validate: (value) => {\n if (!value || value.trim() === '') {\n return 'This field is required';\n }\n return true;\n}`
320 | });
321 | }
322 |
323 | return {
324 | isValid: errors.length === 0,
325 | errors: errors.length > 0 ? errors : undefined,
326 | warnings: warnings.length > 0 ? warnings : undefined,
327 | suggestions: suggestions.length > 0 ? suggestions : undefined,
328 | references: [
329 | {
330 | title: "Payload CMS Fields Documentation",
331 | url: "https://payloadcms.com/docs/fields/overview"
332 | }
333 | ]
334 | };
335 | } catch (error) {
336 | if (error instanceof z.ZodError) {
337 | return {
338 | isValid: false,
339 | errors: error.errors.map(e => `${e.path.join('.')}: ${e.message}`),
340 | references: [
341 | {
342 | title: "Payload CMS Fields Documentation",
343 | url: "https://payloadcms.com/docs/fields/overview"
344 | }
345 | ]
346 | };
347 | }
348 |
349 | return {
350 | isValid: false,
351 | errors: [(error as Error).message],
352 | references: [
353 | {
354 | title: "Payload CMS Fields Documentation",
355 | url: "https://payloadcms.com/docs/fields/overview"
356 | }
357 | ]
358 | };
359 | }
360 | };
361 |
362 | /**
363 | * Validates a Payload CMS global
364 | */
365 | export const validateGlobal = (code: string): ValidationResult => {
366 | try {
367 | // Parse the code to get a JavaScript object
368 | const global = eval(`(${code})`);
369 |
370 | // Validate against schema
371 | GlobalSchema.parse(global);
372 |
373 | const errors: string[] = [];
374 | const warnings: string[] = [];
375 | const suggestions: { message: string; code?: string }[] = [];
376 |
377 | // Check naming conventions
378 | if (global.slug) {
379 | errors.push(...commonValidationRules.namingConventions(global.slug));
380 | errors.push(...commonValidationRules.reservedWords(global.slug));
381 | }
382 |
383 | // Check fields
384 | if (global.fields) {
385 | for (const field of global.fields) {
386 | if (field.name) {
387 | errors.push(...commonValidationRules.namingConventions(field.name));
388 | errors.push(...commonValidationRules.reservedWords(field.name));
389 | }
390 | }
391 |
392 | // Security checks
393 | warnings.push(...securityValidationRules.accessControl(global));
394 | warnings.push(...securityValidationRules.authFields(global.fields));
395 | }
396 |
397 | return {
398 | isValid: errors.length === 0,
399 | errors: errors.length > 0 ? errors : undefined,
400 | warnings: warnings.length > 0 ? warnings : undefined,
401 | suggestions: suggestions.length > 0 ? suggestions : undefined,
402 | references: [
403 | {
404 | title: "Payload CMS Globals Documentation",
405 | url: "https://payloadcms.com/docs/configuration/globals"
406 | }
407 | ]
408 | };
409 | } catch (error) {
410 | if (error instanceof z.ZodError) {
411 | return {
412 | isValid: false,
413 | errors: error.errors.map(e => `${e.path.join('.')}: ${e.message}`),
414 | references: [
415 | {
416 | title: "Payload CMS Globals Documentation",
417 | url: "https://payloadcms.com/docs/configuration/globals"
418 | }
419 | ]
420 | };
421 | }
422 |
423 | return {
424 | isValid: false,
425 | errors: [(error as Error).message],
426 | references: [
427 | {
428 | title: "Payload CMS Globals Documentation",
429 | url: "https://payloadcms.com/docs/configuration/globals"
430 | }
431 | ]
432 | };
433 | }
434 | };
435 |
436 | /**
437 | * Validates a Payload CMS config
438 | */
439 | export const validateConfig = (code: string): ValidationResult => {
440 | try {
441 | // Parse the code to get a JavaScript object
442 | const config = eval(`(${code})`);
443 |
444 | // Validate against schema
445 | ConfigSchema.parse(config);
446 |
447 | const errors: string[] = [];
448 | const warnings: string[] = [];
449 | const suggestions: { message: string; code?: string }[] = [];
450 |
451 | // Config-specific validations
452 | if (!config.serverURL) {
453 | warnings.push("Missing serverURL in config. This is required for proper URL generation.");
454 | suggestions.push({
455 | message: "Add serverURL to your config",
456 | code: `serverURL: 'http://localhost:3000'`
457 | });
458 | }
459 |
460 | if (!config.admin) {
461 | suggestions.push({
462 | message: "Consider configuring the admin panel",
463 | code: `admin: {\n user: 'users',\n meta: {\n titleSuffix: '- My Payload App',\n favicon: '/favicon.ico',\n }\n}`
464 | });
465 | }
466 |
467 | return {
468 | isValid: errors.length === 0,
469 | errors: errors.length > 0 ? errors : undefined,
470 | warnings: warnings.length > 0 ? warnings : undefined,
471 | suggestions: suggestions.length > 0 ? suggestions : undefined,
472 | references: [
473 | {
474 | title: "Payload CMS Configuration Documentation",
475 | url: "https://payloadcms.com/docs/configuration/overview"
476 | }
477 | ]
478 | };
479 | } catch (error) {
480 | if (error instanceof z.ZodError) {
481 | return {
482 | isValid: false,
483 | errors: error.errors.map(e => `${e.path.join('.')}: ${e.message}`),
484 | references: [
485 | {
486 | title: "Payload CMS Configuration Documentation",
487 | url: "https://payloadcms.com/docs/configuration/overview"
488 | }
489 | ]
490 | };
491 | }
492 |
493 | return {
494 | isValid: false,
495 | errors: [(error as Error).message],
496 | references: [
497 | {
498 | title: "Payload CMS Configuration Documentation",
499 | url: "https://payloadcms.com/docs/configuration/overview"
500 | }
501 | ]
502 | };
503 | }
504 | };
505 |
506 | /**
507 | * Validates Payload CMS code based on the file type
508 | */
509 | export const validatePayloadCode = (code: string, fileType: FileType): ValidationResult => {
510 | switch (fileType) {
511 | case 'collection':
512 | return validateCollection(code);
513 | case 'field':
514 | return validateField(code);
515 | case 'global':
516 | return validateGlobal(code);
517 | case 'config':
518 | return validateConfig(code);
519 | default:
520 | return {
521 | isValid: false,
522 | errors: [`Unknown file type: ${fileType}`],
523 | };
524 | }
525 | };
--------------------------------------------------------------------------------
/lib/mcp-api-handler.ts:
--------------------------------------------------------------------------------
1 | import getRawBody from "raw-body";
2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3 | import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
4 | import { IncomingHttpHeaders, IncomingMessage, ServerResponse } from "http";
5 | import { createClient } from "redis";
6 | import { Socket } from "net";
7 | import { Readable } from "stream";
8 | import { ServerOptions } from "@modelcontextprotocol/sdk/server/index.js";
9 | import vercelJson from "../vercel.json";
10 |
11 | interface SerializedRequest {
12 | requestId: string;
13 | url: string;
14 | method: string;
15 | body: string;
16 | headers: IncomingHttpHeaders;
17 | }
18 |
19 | export function initializeMcpApiHandler(
20 | initializeServer: (server: McpServer) => void,
21 | serverOptions: ServerOptions = {}
22 | ) {
23 | const maxDuration =
24 | vercelJson?.functions?.["api/server.ts"]?.maxDuration || 800;
25 |
26 | // Clean the Redis URL if it contains variable name or quotes
27 | const cleanRedisUrl = (url: string | undefined): string | undefined => {
28 | if (!url) return undefined;
29 |
30 | // If the URL contains KV_URL= or REDIS_URL=, extract just the URL part
31 | if (url.includes('KV_URL=') || url.includes('REDIS_URL=')) {
32 | const match = url.match(/(?:KV_URL=|REDIS_URL=)["']?(rediss?:\/\/[^"']+)["']?/);
33 | return match ? match[1] : url;
34 | }
35 |
36 | // Remove any surrounding quotes
37 | return url.replace(/^["'](.+)["']$/, '$1');
38 | };
39 |
40 | const redisUrl = cleanRedisUrl(process.env.REDIS_URL) || cleanRedisUrl(process.env.KV_URL);
41 | if (!redisUrl) {
42 | throw new Error("REDIS_URL or KV_URL environment variable is not set");
43 | }
44 |
45 | console.log("Using Redis URL:", redisUrl);
46 |
47 | // Get optional configuration from environment variables
48 | const connectTimeout = process.env.REDIS_CONNECT_TIMEOUT ?
49 | parseInt(process.env.REDIS_CONNECT_TIMEOUT, 10) : 30000;
50 | const keepAlive = process.env.REDIS_KEEP_ALIVE ?
51 | parseInt(process.env.REDIS_KEEP_ALIVE, 10) : 5000;
52 | const pingInterval = process.env.REDIS_PING_INTERVAL ?
53 | parseInt(process.env.REDIS_PING_INTERVAL, 10) : 1000;
54 | const commandTimeout = process.env.REDIS_COMMAND_TIMEOUT ?
55 | parseInt(process.env.REDIS_COMMAND_TIMEOUT, 10) : 5000;
56 | const heartbeatInterval = process.env.REDIS_HEARTBEAT_INTERVAL ?
57 | parseInt(process.env.REDIS_HEARTBEAT_INTERVAL, 10) : 30000;
58 | const persistenceInterval = process.env.REDIS_PERSISTENCE_INTERVAL ?
59 | parseInt(process.env.REDIS_PERSISTENCE_INTERVAL, 10) : 60000;
60 | const maxReconnectAttempts = process.env.REDIS_MAX_RECONNECT_ATTEMPTS ?
61 | parseInt(process.env.REDIS_MAX_RECONNECT_ATTEMPTS, 10) : 5;
62 | const tlsVerify = process.env.REDIS_TLS_VERIFY ?
63 | process.env.REDIS_TLS_VERIFY.toLowerCase() === 'true' : false;
64 |
65 | // Global connection state
66 | let isRedisConnected = false;
67 | let isRedisPublisherConnected = false;
68 | let reconnectAttempts = 0;
69 | let heartbeatIntervalId: NodeJS.Timeout | null = null;
70 | let persistenceIntervalId: NodeJS.Timeout | null = null;
71 |
72 | // Create Redis clients with maximum persistence settings
73 | const redis = createClient({
74 | url: redisUrl,
75 | socket: {
76 | reconnectStrategy: (retries) => {
77 | // More aggressive exponential backoff with a maximum delay of 5 seconds
78 | const delay = Math.min(Math.pow(1.5, retries) * 100, 5000);
79 | console.log(`Redis reconnecting in ${delay}ms (attempt ${retries})`);
80 | reconnectAttempts = retries;
81 | return delay;
82 | },
83 | connectTimeout: connectTimeout,
84 | keepAlive: keepAlive,
85 | noDelay: true, // Disable Nagle's algorithm for faster response
86 | tls: redisUrl.startsWith('rediss://') ? { rejectUnauthorized: tlsVerify } : undefined,
87 | },
88 | pingInterval: pingInterval,
89 | disableOfflineQueue: false, // Queue commands when disconnected
90 | commandTimeout: commandTimeout,
91 | retryStrategy: (times) => {
92 | // Very aggressive retry strategy for commands
93 | return Math.min(times * 100, 2000);
94 | },
95 | // Force auto-reconnect
96 | autoResubscribe: true,
97 | autoResendUnfulfilledCommands: true,
98 | enableReadyCheck: true,
99 | enableOfflineQueue: true,
100 | maxRetriesPerRequest: 20, // Retry commands many times
101 | });
102 |
103 | const redisPublisher = createClient({
104 | url: redisUrl,
105 | socket: {
106 | reconnectStrategy: (retries) => {
107 | // More aggressive exponential backoff with a maximum delay of 5 seconds
108 | const delay = Math.min(Math.pow(1.5, retries) * 100, 5000);
109 | console.log(`Redis publisher reconnecting in ${delay}ms (attempt ${retries})`);
110 | return delay;
111 | },
112 | connectTimeout: connectTimeout,
113 | keepAlive: keepAlive,
114 | noDelay: true, // Disable Nagle's algorithm for faster response
115 | tls: redisUrl.startsWith('rediss://') ? { rejectUnauthorized: tlsVerify } : undefined,
116 | },
117 | pingInterval: pingInterval,
118 | disableOfflineQueue: false, // Queue commands when disconnected
119 | commandTimeout: commandTimeout,
120 | retryStrategy: (times) => {
121 | // Very aggressive retry strategy for commands
122 | return Math.min(times * 100, 2000);
123 | },
124 | // Force auto-reconnect
125 | autoResubscribe: true,
126 | autoResendUnfulfilledCommands: true,
127 | enableReadyCheck: true,
128 | enableOfflineQueue: true,
129 | maxRetriesPerRequest: 20, // Retry commands many times
130 | });
131 |
132 | // Enhanced event listeners
133 | redis.on("error", (err) => {
134 | console.error("Redis error", err);
135 | isRedisConnected = false;
136 | ensureRedisConnection();
137 | });
138 | redis.on("reconnecting", () => {
139 | console.log("Redis reconnecting...");
140 | isRedisConnected = false;
141 | });
142 | redis.on("connect", () => {
143 | console.log("Redis connected");
144 | isRedisConnected = true;
145 | reconnectAttempts = 0;
146 | });
147 | redis.on("end", () => {
148 | console.log("Redis disconnected");
149 | isRedisConnected = false;
150 | ensureRedisConnection();
151 | });
152 | redis.on("ready", () => {
153 | console.log("Redis ready");
154 | isRedisConnected = true;
155 | });
156 |
157 | redisPublisher.on("error", (err) => {
158 | console.error("Redis publisher error", err);
159 | isRedisPublisherConnected = false;
160 | ensureRedisConnection();
161 | });
162 | redisPublisher.on("reconnecting", () => {
163 | console.log("Redis publisher reconnecting...");
164 | isRedisPublisherConnected = false;
165 | });
166 | redisPublisher.on("connect", () => {
167 | console.log("Redis publisher connected");
168 | isRedisPublisherConnected = true;
169 | });
170 | redisPublisher.on("end", () => {
171 | console.log("Redis publisher disconnected");
172 | isRedisPublisherConnected = false;
173 | ensureRedisConnection();
174 | });
175 | redisPublisher.on("ready", () => {
176 | console.log("Redis publisher ready");
177 | isRedisPublisherConnected = true;
178 | });
179 |
180 | // Initial connection promise
181 | const redisPromise = Promise.all([redis.connect(), redisPublisher.connect()]);
182 |
183 | let servers: McpServer[] = [];
184 |
185 | // More aggressive function to handle reconnection
186 | const ensureRedisConnection = async () => {
187 | try {
188 | if (!isRedisConnected) {
189 | console.log("Ensuring Redis connection...");
190 | try {
191 | await redis.disconnect();
192 | } catch (e) {
193 | // Ignore disconnect errors
194 | }
195 | await redis.connect();
196 | }
197 | if (!isRedisPublisherConnected) {
198 | console.log("Ensuring Redis publisher connection...");
199 | try {
200 | await redisPublisher.disconnect();
201 | } catch (e) {
202 | // Ignore disconnect errors
203 | }
204 | await redisPublisher.connect();
205 | }
206 | } catch (error) {
207 | console.error("Failed to reconnect to Redis:", error);
208 | // Schedule another attempt
209 | setTimeout(ensureRedisConnection, 2000);
210 | }
211 | };
212 |
213 | // Set up a more frequent heartbeat to keep the Redis connection alive
214 | heartbeatIntervalId = setInterval(async () => {
215 | try {
216 | if (isRedisConnected) {
217 | // Send a ping to keep the connection alive
218 | await redis.ping();
219 | console.log("Redis heartbeat: connection alive");
220 | } else {
221 | console.log("Redis heartbeat: reconnecting...");
222 | await ensureRedisConnection();
223 | }
224 | } catch (error) {
225 | console.error("Redis heartbeat error:", error);
226 | isRedisConnected = false;
227 | await ensureRedisConnection();
228 | }
229 | }, heartbeatInterval);
230 |
231 | // Additional persistence interval that forces reconnection periodically
232 | persistenceIntervalId = setInterval(async () => {
233 | console.log("Persistence check: ensuring Redis connections are healthy");
234 | // Force a ping to check connection health
235 | try {
236 | await redis.ping();
237 | await redisPublisher.ping();
238 | } catch (error) {
239 | console.error("Persistence check failed:", error);
240 | await ensureRedisConnection();
241 | }
242 |
243 | // If we've had too many reconnect attempts, force a clean reconnection
244 | if (reconnectAttempts > maxReconnectAttempts) {
245 | console.log(`Too many reconnect attempts (${reconnectAttempts}/${maxReconnectAttempts}), forcing clean reconnection`);
246 | try {
247 | await redis.disconnect();
248 | await redisPublisher.disconnect();
249 | } catch (e) {
250 | // Ignore disconnect errors
251 | }
252 |
253 | // Short delay before reconnecting
254 | await new Promise(resolve => setTimeout(resolve, 1000));
255 |
256 | try {
257 | await redis.connect();
258 | await redisPublisher.connect();
259 | reconnectAttempts = 0;
260 | } catch (error) {
261 | console.error("Forced reconnection failed:", error);
262 | }
263 | }
264 | }, persistenceInterval);
265 |
266 | // Handle process termination to clean up resources
267 | const handleTermination = async () => {
268 | console.log("Cleaning up resources before termination");
269 | if (heartbeatIntervalId) clearInterval(heartbeatIntervalId);
270 | if (persistenceIntervalId) clearInterval(persistenceIntervalId);
271 | try {
272 | await redis.disconnect();
273 | await redisPublisher.disconnect();
274 | } catch (e) {
275 | // Ignore disconnect errors
276 | }
277 | process.exit(0);
278 | };
279 |
280 | // Register termination handlers if they haven't been registered yet
281 | if (process.listenerCount('SIGTERM') === 0) {
282 | process.on('SIGTERM', handleTermination);
283 | }
284 | if (process.listenerCount('SIGINT') === 0) {
285 | process.on('SIGINT', handleTermination);
286 | }
287 |
288 | return async function mcpApiHandler(
289 | req: IncomingMessage,
290 | res: ServerResponse
291 | ) {
292 | // Ensure Redis connection before processing request
293 | await ensureRedisConnection();
294 |
295 | await redisPromise;
296 | const url = new URL(req.url || "", "https://example.com");
297 | if (url.pathname === "/sse") {
298 | console.log("Got new SSE connection");
299 |
300 | const transport = new SSEServerTransport("/message", res);
301 | const sessionId = transport.sessionId;
302 | const server = new McpServer(
303 | {
304 | name: "mcp-typescript server on vercel",
305 | version: "0.1.0",
306 | },
307 | serverOptions
308 | );
309 | initializeServer(server);
310 |
311 | servers.push(server);
312 |
313 | server.server.onclose = () => {
314 | console.log("SSE connection closed");
315 | servers = servers.filter((s) => s !== server);
316 | };
317 |
318 | let logs: {
319 | type: "log" | "error";
320 | messages: string[];
321 | }[] = [];
322 | // This ensures that we logs in the context of the right invocation since the subscriber
323 | // is not itself invoked in request context.
324 | function logInContext(severity: "log" | "error", ...messages: string[]) {
325 | logs.push({
326 | type: severity,
327 | messages,
328 | });
329 | }
330 |
331 | // Handles messages originally received via /message
332 | const handleMessage = async (message: string) => {
333 | console.log("Received message from Redis", message);
334 | logInContext("log", "Received message from Redis", message);
335 | const request = JSON.parse(message) as SerializedRequest;
336 |
337 | // Make in IncomingMessage object because that is what the SDK expects.
338 | const req = createFakeIncomingMessage({
339 | method: request.method,
340 | url: request.url,
341 | headers: request.headers,
342 | body: request.body,
343 | });
344 | const syntheticRes = new ServerResponse(req);
345 | let status = 100;
346 | let body = "";
347 | syntheticRes.writeHead = (statusCode: number) => {
348 | status = statusCode;
349 | return syntheticRes;
350 | };
351 | syntheticRes.end = (b: unknown) => {
352 | body = b as string;
353 | return syntheticRes;
354 | };
355 | await transport.handlePostMessage(req, syntheticRes);
356 |
357 | await redisPublisher.publish(
358 | `responses:${sessionId}:${request.requestId}`,
359 | JSON.stringify({
360 | status,
361 | body,
362 | })
363 | );
364 |
365 | if (status >= 200 && status < 300) {
366 | logInContext(
367 | "log",
368 | `Request ${sessionId}:${request.requestId} succeeded: ${body}`
369 | );
370 | } else {
371 | logInContext(
372 | "error",
373 | `Message for ${sessionId}:${request.requestId} failed with status ${status}: ${body}`
374 | );
375 | }
376 | };
377 |
378 | const interval = setInterval(() => {
379 | for (const log of logs) {
380 | console[log.type].call(console, ...log.messages);
381 | }
382 | logs = [];
383 | }, 100);
384 |
385 | await redis.subscribe(`requests:${sessionId}`, handleMessage);
386 | console.log(`Subscribed to requests:${sessionId}`);
387 |
388 | let timeout: NodeJS.Timeout;
389 | let resolveTimeout: (value: unknown) => void;
390 | const waitPromise = new Promise((resolve) => {
391 | resolveTimeout = resolve;
392 | timeout = setTimeout(() => {
393 | resolve("max duration reached");
394 | }, (maxDuration - 5) * 1000);
395 | });
396 |
397 | async function cleanup() {
398 | clearTimeout(timeout);
399 | clearInterval(interval);
400 | await redis.unsubscribe(`requests:${sessionId}`, handleMessage);
401 | console.log("Done");
402 | res.statusCode = 200;
403 | res.end();
404 | }
405 | req.on("close", () => resolveTimeout("client hang up"));
406 |
407 | // Handle process termination to clean up resources
408 | const handleSessionTermination = async () => {
409 | console.log("Cleaning up session resources before termination");
410 | await cleanup();
411 | };
412 |
413 | // Register session-specific cleanup
414 | if (process.listenerCount('SIGTERM') === 1) { // Only our global handler
415 | process.on('SIGTERM', handleSessionTermination);
416 | }
417 | if (process.listenerCount('SIGINT') === 1) { // Only our global handler
418 | process.on('SIGINT', handleSessionTermination);
419 | }
420 |
421 | await server.connect(transport);
422 | const closeReason = await waitPromise;
423 | console.log(closeReason);
424 | await cleanup();
425 |
426 | // Remove session-specific handlers
427 | process.removeListener('SIGTERM', handleSessionTermination);
428 | process.removeListener('SIGINT', handleSessionTermination);
429 | } else if (url.pathname === "/message") {
430 | console.log("Received message");
431 |
432 | const body = await getRawBody(req, {
433 | length: req.headers["content-length"],
434 | encoding: "utf-8",
435 | });
436 |
437 | const sessionId = url.searchParams.get("sessionId") || "";
438 | if (!sessionId) {
439 | res.statusCode = 400;
440 | res.end("No sessionId provided");
441 | return;
442 | }
443 | const requestId = crypto.randomUUID();
444 | const serializedRequest: SerializedRequest = {
445 | requestId,
446 | url: req.url || "",
447 | method: req.method || "",
448 | body: body,
449 | headers: req.headers,
450 | };
451 |
452 | // Handles responses from the /sse endpoint.
453 | await redis.subscribe(
454 | `responses:${sessionId}:${requestId}`,
455 | (message) => {
456 | clearTimeout(timeout);
457 | const response = JSON.parse(message) as {
458 | status: number;
459 | body: string;
460 | };
461 | res.statusCode = response.status;
462 | res.end(response.body);
463 | }
464 | );
465 |
466 | // Queue the request in Redis so that a subscriber can pick it up.
467 | // One queue per session.
468 | await redisPublisher.publish(
469 | `requests:${sessionId}`,
470 | JSON.stringify(serializedRequest)
471 | );
472 | console.log(`Published requests:${sessionId}`, serializedRequest);
473 |
474 | let timeout = setTimeout(async () => {
475 | await redis.unsubscribe(`responses:${sessionId}:${requestId}`);
476 | res.statusCode = 408;
477 | res.end("Request timed out");
478 | }, 10 * 1000);
479 |
480 | res.on("close", async () => {
481 | clearTimeout(timeout);
482 | await redis.unsubscribe(`responses:${sessionId}:${requestId}`);
483 | });
484 | } else {
485 | res.statusCode = 404;
486 | res.end("Not found");
487 | }
488 | };
489 | }
490 |
491 | // Define the options interface
492 | interface FakeIncomingMessageOptions {
493 | method?: string;
494 | url?: string;
495 | headers?: IncomingHttpHeaders;
496 | body?: string | Buffer | Record | null;
497 | socket?: Socket;
498 | }
499 |
500 | // Create a fake IncomingMessage
501 | function createFakeIncomingMessage(
502 | options: FakeIncomingMessageOptions = {}
503 | ): IncomingMessage {
504 | const {
505 | method = "GET",
506 | url = "/",
507 | headers = {},
508 | body = null,
509 | socket = new Socket(),
510 | } = options;
511 |
512 | // Create a readable stream that will be used as the base for IncomingMessage
513 | const readable = new Readable();
514 | readable._read = (): void => {}; // Required implementation
515 |
516 | // Add the body content if provided
517 | if (body) {
518 | if (typeof body === "string") {
519 | readable.push(body);
520 | } else if (Buffer.isBuffer(body)) {
521 | readable.push(body);
522 | } else {
523 | readable.push(JSON.stringify(body));
524 | }
525 | readable.push(null); // Signal the end of the stream
526 | }
527 |
528 | // Create the IncomingMessage instance
529 | const req = new IncomingMessage(socket);
530 |
531 | // Set the properties
532 | req.method = method;
533 | req.url = url;
534 | req.headers = headers;
535 |
536 | // Copy over the stream methods
537 | req.push = readable.push.bind(readable);
538 | req.read = readable.read.bind(readable);
539 | req.on = readable.on.bind(readable);
540 | req.pipe = readable.pipe.bind(readable);
541 |
542 | return req;
543 | }
544 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🚀 Payload CMS 3.0 MCP Server
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
A specialized MCP server for Payload CMS 3.0
15 |
Validate code, generate templates, and scaffold projects following best practices
16 |
17 |
18 |
19 |
20 | ## 📋 Overview
21 |
22 | The Payload CMS 3.0 MCP Server is a specialized Model Context Protocol server designed to enhance your Payload CMS development experience. It helps developers build better Payload CMS applications by providing code validation, template generation, and project scaffolding capabilities that follow best practices.
23 |
24 |
25 |
26 | ## ✨ Features
27 |
28 |
29 |
30 |
31 |
32 | 📚
33 | Code Validation
34 | Validate Payload CMS code for collections, fields, globals, and config files with detailed feedback on syntax errors and best practices.
35 |
36 |
37 | 🔍
38 | Code Generation
39 | Generate code templates for collections, fields, globals, access control, hooks, endpoints, plugins, blocks, and migrations.
40 |
41 |
42 | 🚀
43 | Project Scaffolding
44 | Scaffold entire Payload CMS projects with validated options for consistency and adherence to best practices.
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | ## 🔧 Payload CMS 3.0 Capabilities
53 |
54 | ### Validation Tools
55 |
56 | * `validate` - Validate code for collections, fields, globals, and config
57 | * `query` - Query validation rules and best practices
58 | * `mcp_query` - Execute SQL-like queries for Payload CMS structures
59 |
60 | ### Code Generation
61 |
62 | * `generate_template` - Generate code templates for various components
63 | * `generate_collection` - Create complete collection definitions
64 | * `generate_field` - Generate field definitions with proper typing
65 |
66 | ### Project Setup
67 |
68 | * `scaffold_project` - Create entire Payload CMS project structures
69 | * `validate_scaffold_options` - Ensure scaffold options follow best practices (used internally by scaffold_project)
70 |
71 |
72 |
73 | ## 📝 Detailed Tool Reference
74 |
75 | ### Validation Tools
76 |
77 | #### `validate`
78 | Validates Payload CMS code for syntax and best practices.
79 |
80 | **Parameters:**
81 | - `code` (string): The code to validate
82 | - `fileType` (enum): Type of file - "collection", "field", "global", or "config"
83 |
84 | **Example Prompt:**
85 | ```
86 | Can you validate this Payload CMS collection code?
87 |
88 | ```typescript
89 | export const Posts = {
90 | slug: 'posts',
91 | fields: [
92 | {
93 | name: 'title',
94 | type: 'text',
95 | required: true,
96 | },
97 | {
98 | name: 'content',
99 | type: 'richText',
100 | }
101 | ],
102 | admin: {
103 | useAsTitle: 'title',
104 | }
105 | }
106 | ```
107 |
108 | #### `query`
109 | Queries validation rules and best practices for Payload CMS.
110 |
111 | **Parameters:**
112 | - `query` (string): The query string
113 | - `fileType` (optional enum): Type of file - "collection", "field", "global", or "config"
114 |
115 | **Example Prompt:**
116 | ```
117 | What are the best practices for implementing access control in Payload CMS collections?
118 | ```
119 |
120 | #### `mcp_query`
121 | Executes SQL-like queries against Payload CMS structures.
122 |
123 | **Parameters:**
124 | - `sql` (string): SQL-like query string
125 |
126 | **Example Prompt:**
127 | ```
128 | Can you execute this query to find all valid field types in Payload CMS?
129 | SELECT field_types FROM payload_schema WHERE version = '3.0'
130 | ```
131 |
132 | ### Code Generation
133 |
134 | #### `generate_template`
135 | Generates code templates for various Payload CMS components.
136 |
137 | **Parameters:**
138 | - `templateType` (enum): Type of template - "collection", "field", "global", "config", "access-control", "hook", "endpoint", "plugin", "block", "migration"
139 | - `options` (record): Configuration options for the template
140 |
141 | **Example Prompt:**
142 | ```
143 | Generate a template for a Payload CMS hook that logs when a document is created.
144 | ```
145 |
146 | #### `generate_collection`
147 | Generates a complete Payload CMS collection definition.
148 |
149 | **Parameters:**
150 | - `slug` (string): Collection slug
151 | - `fields` (optional array): Array of field objects
152 | - `auth` (optional boolean): Whether this is an auth collection
153 | - `timestamps` (optional boolean): Whether to include timestamps
154 | - `admin` (optional object): Admin panel configuration
155 | - `hooks` (optional boolean): Whether to include hooks
156 | - `access` (optional boolean): Whether to include access control
157 | - `versions` (optional boolean): Whether to enable versioning
158 |
159 | **Example Prompt:**
160 | ```
161 | Generate a Payload CMS collection for a blog with title, content, author, and published date fields. Include timestamps and versioning.
162 | ```
163 |
164 | #### `generate_field`
165 | Generates a Payload CMS field definition.
166 |
167 | **Parameters:**
168 | - `name` (string): Field name
169 | - `type` (string): Field type
170 | - `required` (optional boolean): Whether the field is required
171 | - `unique` (optional boolean): Whether the field should be unique
172 | - `localized` (optional boolean): Whether the field should be localized
173 | - `access` (optional boolean): Whether to include access control
174 | - `admin` (optional object): Admin panel configuration
175 | - `validation` (optional boolean): Whether to include validation
176 | - `defaultValue` (optional any): Default value for the field
177 |
178 | **Example Prompt:**
179 | ```
180 | Generate a Payload CMS image field with validation that requires alt text and has a description in the admin panel.
181 | ```
182 |
183 | ### Project Setup
184 |
185 | #### `scaffold_project`
186 | Scaffolds a complete Payload CMS project structure.
187 |
188 | **Parameters:**
189 | - `projectName` (string): Name of the project
190 | - `description` (optional string): Project description
191 | - `serverUrl` (optional string): Server URL
192 | - `database` (optional enum): Database type - "mongodb" or "postgres"
193 | - `auth` (optional boolean): Whether to include authentication
194 | - `admin` (optional object): Admin panel configuration
195 | - `collections` (optional array): Array of collection objects
196 | - `globals` (optional array): Array of global objects
197 | - `blocks` (optional array): Array of block objects
198 | - `plugins` (optional array): Array of plugin strings
199 | - `typescript` (optional boolean): Whether to use TypeScript
200 |
201 | **Example Prompt:**
202 | ```
203 | Scaffold a Payload CMS project called "blog-platform" with MongoDB, authentication, and collections for posts, categories, and users. Include a global for site settings.
204 | ```
205 |
206 |
207 |
208 | ## 🚀 Getting Started
209 |
210 | ### 1. Prerequisites
211 |
212 | Before you begin, make sure you have:
213 |
214 | * Node.js 18+ (required for Payload CMS 3.0)
215 | * An active Railway account
216 | * A Railway API token (create one at [railway.app/account/tokens](https://railway.app/account/tokens))
217 | * Basic familiarity with Payload CMS 3.0 concepts
218 |
219 | ### 2. Configure Cursor
220 |
221 | To use with Cursor IDE:
222 |
223 | 1. Open Cursor Settings
224 | 2. Go to MCP Servers section
225 | 3. Add a new MCP server
226 | 4. Name it "Payload CMS 3.0 MCP"
227 | 5. Set Transport Type to "Command"
228 | 6. Set Command to: `railway run --service=YOUR_SERVICE_ID`
229 | 7. Save the configuration
230 |
231 | ### 3. Using the MCP Server
232 |
233 | Once configured, you can use these tools in your AI prompts:
234 |
235 | * **Code Validation:** "Validate this Payload CMS collection code"
236 | * **Template Generation:** "Generate a template for a media collection"
237 | * **Query Rules:** "Query validation rules for access control"
238 | * **SQL-like Queries:** "Execute a query to find field types for collections"
239 |
240 | ### 4. Example Workflow
241 |
242 | A typical workflow with the Payload CMS MCP Server:
243 |
244 | 1. Generate a collection template with specific fields
245 | 2. Validate the generated code for best practices
246 | 3. Query for specific validation rules to improve the code
247 | 4. Generate additional components like hooks or endpoints
248 | 5. Scaffold a complete project structure when ready
249 |
250 | ### 5. Complete Workflow Example
251 |
252 | Here's a complete example of how you might use the MCP server in a conversation:
253 |
254 | ```
255 | User: I need to create a Payload CMS collection for a product catalog.
256 |
257 | AI: I'll help you create a product catalog collection. Let me generate a template for you.
258 |
259 | [Uses generate_collection tool]
260 |
261 | Here's a collection template for your product catalog:
262 |
263 | ```typescript
264 | import { CollectionConfig } from 'payload/types';
265 |
266 | export const Products: CollectionConfig = {
267 | slug: 'products',
268 | admin: {
269 | useAsTitle: 'name',
270 | defaultColumns: ['name', 'price', 'category', 'inStock'],
271 | group: 'Shop',
272 | },
273 | access: {
274 | read: () => true,
275 | },
276 | fields: [
277 | {
278 | name: 'name',
279 | type: 'text',
280 | required: true,
281 | },
282 | {
283 | name: 'description',
284 | type: 'richText',
285 | },
286 | {
287 | name: 'price',
288 | type: 'number',
289 | required: true,
290 | },
291 | {
292 | name: 'category',
293 | type: 'relationship',
294 | relationTo: 'categories',
295 | required: true,
296 | },
297 | {
298 | name: 'images',
299 | type: 'array',
300 | fields: [
301 | {
302 | name: 'image',
303 | type: 'upload',
304 | relationTo: 'media',
305 | required: true,
306 | },
307 | {
308 | name: 'altText',
309 | type: 'text',
310 | required: true,
311 | }
312 | ]
313 | },
314 | {
315 | name: 'inStock',
316 | type: 'checkbox',
317 | defaultValue: true,
318 | },
319 | ],
320 | timestamps: true,
321 | }
322 | ```
323 |
324 | User: Can you validate this code to make sure it follows best practices?
325 |
326 | AI: I'll validate the code for you.
327 |
328 | [Uses validate tool]
329 |
330 | The validation results show that your code is valid and follows best practices. Here are a few suggestions:
331 |
332 | 1. Consider adding a maxDepth to your category relationship field to prevent deep queries
333 | 2. You might want to add access control for create, update, and delete operations
334 | 3. Consider adding hooks for inventory management when products are updated
335 |
336 | User: What are the best practices for access control in Payload CMS?
337 |
338 | AI: Let me query the validation rules for access control.
339 |
340 | [Uses query tool]
341 |
342 | Here are the best practices for access control in Payload CMS:
343 |
344 | 1. Always define explicit access control functions for create, read, update, and delete operations
345 | 2. Use role-based access control for admin users
346 | 3. Implement field-level access control for sensitive data
347 | 4. Use collection-level access control for broad permissions
348 | 5. Consider using hooks alongside access control for complex logic
349 |
350 | User: Now I need to scaffold a complete project with this product collection and a categories collection.
351 |
352 | AI: I'll scaffold a complete project for you.
353 |
354 | [Uses scaffold_project tool]
355 |
356 | I've generated a complete Payload CMS project structure with the following:
357 |
358 | - Product collection as you specified
359 | - Categories collection with name and description fields
360 | - Media collection for product images
361 | - Authentication for admin users
362 | - TypeScript configuration
363 | - MongoDB database setup
364 | - All necessary files and configurations
365 |
366 | The project is ready to be initialized with `npm install` and `npm run dev`.
367 |
368 |
369 |
370 | ## 📋 Scaffolding Examples & Detailed Prompts
371 |
372 | ### Project Scaffolding Examples
373 |
374 | When you scaffold a project using the MCP server, you'll receive a complete project structure. Here's what a scaffolded e-commerce project might look like:
375 |
376 | ```
377 | e-commerce-platform/
378 | ├── .env
379 | ├── .eslintrc.js
380 | ├── .gitignore
381 | ├── README.md
382 | ├── package.json
383 | ├── tsconfig.json
384 | ├── src/
385 | │ ├── payload.config.ts
386 | │ ├── server.ts
387 | │ ├── collections/
388 | │ │ ├── Products.ts
389 | │ │ ├── Categories.ts
390 | │ │ ├── Orders.ts
391 | │ │ ├── Customers.ts
392 | │ │ ├── Media.ts
393 | │ │ └── Users.ts
394 | │ ├── globals/
395 | │ │ ├── Settings.ts
396 | │ │ └── Footer.ts
397 | │ ├── blocks/
398 | │ │ ├── Hero.ts
399 | │ │ ├── ProductGrid.ts
400 | │ │ └── CallToAction.ts
401 | │ ├── fields/
402 | │ │ ├── richText/
403 | │ │ ├── metaImage.ts
404 | │ │ └── slug.ts
405 | │ ├── hooks/
406 | │ │ ├── beforeChange.ts
407 | │ │ └── afterChange.ts
408 | │ ├── access/
409 | │ │ ├── isAdmin.ts
410 | │ │ └── isAdminOrSelf.ts
411 | │ └── utilities/
412 | │ ├── formatSlug.ts
413 | │ └── sendEmail.ts
414 | ```
415 |
416 | ### Example Scaffold Project Prompt (Basic)
417 |
418 | ```
419 | Scaffold a Payload CMS project for a blog platform with the following:
420 | - Project name: blog-platform
421 | - Database: MongoDB
422 | - Authentication: Yes
423 | - Collections: Posts, Categories, Authors, Media
424 | - Globals: SiteSettings
425 | - TypeScript: Yes
426 | ```
427 |
428 | ### Example Scaffold Project Prompt (Detailed)
429 |
430 | ```
431 | Scaffold a comprehensive Payload CMS project for an e-commerce platform with the following specifications:
432 |
433 | Project details:
434 | - Name: luxury-watches-store
435 | - Description: "An e-commerce platform for luxury watches"
436 | - Database: PostgreSQL
437 | - TypeScript: Yes
438 |
439 | Collections needed:
440 | 1. Products collection with:
441 | - Name (text, required)
442 | - Description (rich text)
443 | - Price (number, required)
444 | - SKU (text, unique)
445 | - Brand (relationship to Brands collection)
446 | - Categories (relationship to Categories, multiple)
447 | - Features (array of text fields)
448 | - Specifications (array of key-value pairs)
449 | - Images (array of media uploads with alt text)
450 | - Stock quantity (number)
451 | - Status (select: available, out of stock, discontinued)
452 |
453 | 2. Categories collection with:
454 | - Name (text, required)
455 | - Description (rich text)
456 | - Parent category (self-relationship)
457 | - Image (media upload)
458 |
459 | 3. Brands collection with:
460 | - Name (text, required)
461 | - Logo (media upload)
462 | - Description (rich text)
463 | - Founded year (number)
464 | - Country of origin (text)
465 |
466 | 4. Orders collection with:
467 | - Order number (text, generated)
468 | - Customer (relationship to Users)
469 | - Products (array of relationships to Products with quantity)
470 | - Status (select: pending, processing, shipped, delivered, cancelled)
471 | - Shipping address (group of fields)
472 | - Billing address (group of fields)
473 | - Payment method (select)
474 | - Total amount (number, calculated)
475 | - Notes (text)
476 |
477 | 5. Users collection (auth enabled) with:
478 | - Email (email, required)
479 | - Name (text, required)
480 | - Shipping addresses (array of address groups)
481 | - Order history (relationship to Orders)
482 | - Wishlist (relationship to Products)
483 | - Role (select: customer, admin)
484 |
485 | Globals:
486 | 1. SiteSettings with:
487 | - Site name
488 | - Logo
489 | - Contact information
490 | - Social media links
491 | - SEO defaults
492 |
493 | 2. ShippingMethods with:
494 | - Array of shipping options with prices
495 |
496 | Include access control for:
497 | - Admin-only access to manage products, categories, brands
498 | - Customer access to their own orders and profile
499 | - Public read access to products and categories
500 |
501 | Add hooks for:
502 | - Updating stock when orders are placed
503 | - Generating order numbers
504 | - Sending email notifications on order status changes
505 | ```
506 |
507 | ### Example Collection Creation Prompt (Basic)
508 |
509 | ```
510 | Generate a Payload CMS collection for blog posts with title, content, author, and published date fields.
511 | ```
512 |
513 | ### Example Collection Creation Prompt (Detailed)
514 |
515 | ```
516 | Generate a Payload CMS collection for a real estate property listing with the following specifications:
517 |
518 | Collection name: Properties
519 | Admin configuration:
520 | - Use "title" as the display field
521 | - Group under "Listings" in the admin panel
522 | - Default columns: title, price, location, status, createdAt
523 |
524 | Fields:
525 | 1. Title (text, required)
526 | 2. Slug (text, unique, generated from title)
527 | 3. Description (rich text with basic formatting options)
528 | 4. Price (number, required)
529 | 5. Location (group) with:
530 | - Address (text)
531 | - City (text, required)
532 | - State/Province (text, required)
533 | - Postal code (text)
534 | - Country (select from predefined list)
535 | - Coordinates (point) for map display
536 | 6. Property details (group) with:
537 | - Property type (select: house, apartment, condo, land, commercial)
538 | - Bedrooms (number)
539 | - Bathrooms (number)
540 | - Square footage (number)
541 | - Lot size (number)
542 | - Year built (number)
543 | - Parking spaces (number)
544 | 7. Features (array of checkboxes) including:
545 | - Air conditioning
546 | - Swimming pool
547 | - Garden
548 | - Garage
549 | - Fireplace
550 | - Security system
551 | - Elevator
552 | - Furnished
553 | 8. Images (array of media uploads with alt text and caption)
554 | 9. Documents (array of file uploads for floor plans, certificates, etc.)
555 | 10. Status (select: available, under contract, sold, off market)
556 | 11. Featured (checkbox to highlight on homepage)
557 | 12. Agent (relationship to Users collection, required)
558 | 13. Related properties (relationship to self, multiple)
559 |
560 | Access control:
561 | - Public read access
562 | - Agent can create and edit their own listings
563 | - Admin can manage all listings
564 |
565 | Hooks:
566 | - Before change: Format slug from title
567 | - After change: Notify agent of status changes
568 |
569 | Versioning: Enabled
570 | Timestamps: Enabled
571 | ```
572 |
573 | ### Level of Detail in Prompts
574 |
575 | The MCP server can handle prompts with varying levels of detail:
576 |
577 | #### Minimal Detail (AI fills in the gaps)
578 | ```
579 | Generate a collection for blog posts.
580 | ```
581 |
582 | #### Moderate Detail (Specific requirements)
583 | ```
584 | Generate a collection for blog posts with title, content, featured image, categories, and author fields. Make title and content required.
585 | ```
586 |
587 | #### High Detail (Complete specifications)
588 | ```
589 | Generate a collection for blog posts with:
590 | - Slug: posts
591 | - Fields:
592 | - Title (text, required)
593 | - Content (rich text with custom formatting options)
594 | - Featured image (upload with alt text)
595 | - Categories (relationship to categories collection, multiple)
596 | - Author (relationship to users collection)
597 | - Status (select: draft, published, archived)
598 | - Published date (date)
599 | - SEO (group with title, description, and keywords)
600 | - Admin configuration:
601 | - Use title as display field
602 | - Group under "Content"
603 | - Default columns: title, author, status, publishedDate
604 | - Access control for different user roles
605 | - Hooks for slug generation and notification
606 | - Enable versioning and timestamps
607 | ```
608 |
609 | ### Tips for Effective Prompts
610 |
611 | 1. **Be specific about requirements**: The more details you provide, the more tailored the output will be.
612 |
613 | 2. **Specify relationships**: Clearly indicate how collections relate to each other.
614 |
615 | 3. **Include validation needs**: Mention any validation rules or constraints for fields.
616 |
617 | 4. **Describe admin UI preferences**: Specify how you want the collection to appear in the admin panel.
618 |
619 | 5. **Mention hooks and access control**: If you need specific business logic or security rules, include them in your prompt.
620 |
621 | 6. **Use domain-specific terminology**: Describe your project using terms relevant to your industry or use case.
622 |
623 |
624 |
625 | ## 📄 License
626 |
627 | This project is licensed under the MIT License - see the LICENSE file for details.
628 |
629 |
630 |
631 | ## 🌍 About MATMAX WORLDWIDE
632 |
633 |
634 |
MATMAX WORLDWIDE
635 |
Creating technology that helps humans be more human.
636 |
637 |
638 | We believe in tech for good—tools that enhance our lives while respecting our humanity.
639 |
640 | Join us in building a future where technology serves wellness, connection, and purpose. Together, we can create digital experiences that bring out the best in us all.
641 |
642 | Visit [matmax.world](https://matmax.world) to learn more about our vision for human-centered technology.
643 |
644 |
645 |
646 | ## 🖥️ Running Locally
647 |
648 | You can run the Payload CMS MCP Server locally using npm:
649 |
650 | [](https://www.npmjs.org/package/payload-cms-mcp)
651 | [](https://npmjs.org/package/payload-cms-mcp)
652 |
653 | ### Option 1: Install from npm
654 |
655 | ```bash
656 | # Install globally
657 | npm install -g payload-cms-mcp
658 |
659 | # Run the server
660 | payload-cms-mcp
661 | ```
662 |
663 | ### Option 2: Clone the repository
664 |
665 | 1. Clone the repository:
666 | ```bash
667 | git clone https://github.com/Matmax-Worldwide/payloadcmsmcp.git
668 | cd payloadcmsmcp
669 | ```
670 |
671 | 2. Install dependencies:
672 | ```bash
673 | npm install
674 | ```
675 |
676 | 3. Run the server locally:
677 | ```bash
678 | npm run dev
679 | ```
680 |
681 | Or alternatively:
682 | ```bash
683 | npm run local
684 | ```
685 |
686 | Your MCP server will now be running locally and accessible for development and testing without requiring a Railway API token.
687 |
688 | ## 🚀 Deployment Options
689 |
690 | ### Deploy to Railway (Recommended)
691 |
692 | The easiest way to deploy the MCP server is using Railway's one-click deployment:
693 |
694 | [](https://railway.app/new)
695 |
696 | After clicking the button:
697 | 1. Select "Deploy from GitHub repo"
698 | 2. Search for "Matmax-Worldwide/payloadcmsmcp"
699 | 3. Click "Deploy Now"
700 |
701 | #### Quick Cursor IDE Setup
702 |
703 | After deployment:
704 | 1. Install Railway CLI: `npm install -g @railway/cli`
705 | 2. Login to Railway: `railway login`
706 | 3. Link to your project: `railway link`
707 | 4. In Cursor Settings > MCP Servers, set Command to: `railway run`
--------------------------------------------------------------------------------
/public/index.html.bak:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
13 |
14 |
15 | Payload CMS MCP Server - Validation & Query Service for Payload CMS
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
69 |
70 |
470 |
471 |
472 |
488 |
489 |
490 |
491 |
492 |
Payload CMS MCP Server
493 |
A validation and query service for Payload CMS code, designed to be used with Cursor IDE for AI-assisted development.
494 |
498 |
499 |
500 |
501 |
502 |
503 |
504 |
Features
505 |
Enhance your Payload CMS development experience with powerful validation and query capabilities.
506 |
507 |
508 |
509 | ✓
510 | Code Validation
511 | Validates Payload CMS collections, fields, globals, and other components with detailed feedback on validation issues.
512 |
513 |
514 | 🔍
515 | Smart Queries
516 | Supports SQL-like queries for validation rules, making it easy to find and understand best practices.
517 |
518 |
519 | 💡
520 | Suggestions
521 | Offers intelligent suggestions for improving code quality and security in your Payload CMS applications.
522 |
523 |
524 | 🤖
525 | AI Integration
526 | Seamlessly integrates with Cursor IDE for AI-assisted development of Payload CMS applications.
527 |
528 |
529 |
530 |
531 |
532 |
533 |
534 |
535 |
API Endpoints
536 |
Powerful endpoints to validate and query your Payload CMS code.
537 |
538 |
539 |
540 |
544 |
545 | Server-Sent Events endpoint for real-time communication with the MCP server.
546 |
547 | https://www.payloadcmsmcp.info/sse
548 |
549 |
550 |
551 |
555 |
556 | Alternative SSE endpoint for API-based communication.
557 |
558 | https://www.payloadcmsmcp.info/api/sse
559 |
560 |
561 |
562 |
566 |
567 | Validates Payload CMS code and provides detailed feedback on validation issues.
568 |
569 | https://www.payloadcmsmcp.info/api/validate
570 |
571 |
572 |
573 |
577 |
578 | Query endpoint for retrieving validation rules and best practices.
579 |
580 | https://www.payloadcmsmcp.info/api/query
581 |
582 |
583 |
584 |
585 |
586 |
587 |
588 |
589 |
590 |
591 |
592 |
593 |
MATMAX WORLDWIDE
594 |
Creating technology that helps humans be more human. We believe in tech for good—tools that enhance our lives while respecting our humanity.
595 |
Join us in building a future where technology serves wellness, connection, and purpose. Together, we can create digital experiences that bring out the best in us all.
596 |
Visit matmax.world
597 |
598 |
599 |
600 |
601 |
602 |
603 |
604 |
605 |
630 |
638 |
639 |
640 |
641 |
642 |
656 |
657 |
--------------------------------------------------------------------------------
/lib/payload/generator.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 |
3 | /**
4 | * Types of templates that can be generated
5 | */
6 | export type TemplateType =
7 | | 'collection'
8 | | 'field'
9 | | 'global'
10 | | 'config'
11 | | 'access-control'
12 | | 'hook'
13 | | 'endpoint'
14 | | 'plugin'
15 | | 'block'
16 | | 'migration';
17 |
18 | /**
19 | * Validation schema for template options
20 | */
21 | const templateOptionsSchema = z.record(z.any());
22 |
23 | /**
24 | * Generate a template for Payload CMS 3 based on the template type and options
25 | * @param templateType The type of template to generate
26 | * @param options Options for the template
27 | * @returns The generated code as a string
28 | */
29 | export function generateTemplate(templateType: TemplateType, options: Record): string {
30 | // Validate options
31 | const validationResult = templateOptionsSchema.safeParse(options);
32 | if (!validationResult.success) {
33 | throw new Error(`Invalid template options: ${JSON.stringify(validationResult.error.format())}`);
34 | }
35 |
36 | // Generate the template based on the type
37 | switch (templateType) {
38 | case 'collection':
39 | return generateCollectionTemplate(options);
40 | case 'field':
41 | return generateFieldTemplate(options);
42 | case 'global':
43 | return generateGlobalTemplate(options);
44 | case 'config':
45 | return generateConfigTemplate(options);
46 | case 'access-control':
47 | return generateAccessControlTemplate(options);
48 | case 'hook':
49 | return generateHookTemplate(options);
50 | case 'endpoint':
51 | return generateEndpointTemplate(options);
52 | case 'plugin':
53 | return generatePluginTemplate(options);
54 | case 'block':
55 | return generateBlockTemplate(options);
56 | case 'migration':
57 | return generateMigrationTemplate(options);
58 | default:
59 | throw new Error(`Unsupported template type: ${templateType}`);
60 | }
61 | }
62 |
63 | /**
64 | * Generate a collection template for Payload CMS 3
65 | * @param options Collection options
66 | * @returns The generated collection code
67 | */
68 | function generateCollectionTemplate(options: Record): string {
69 | const {
70 | slug,
71 | fields = [],
72 | auth = false,
73 | timestamps = true,
74 | admin = {},
75 | hooks = false,
76 | access = false,
77 | versions = false,
78 | } = options;
79 |
80 | if (!slug) {
81 | throw new Error('Collection slug is required');
82 | }
83 |
84 | // Generate fields code
85 | const fieldsCode = fields.length > 0
86 | ? fields.map((field: any) => {
87 | return generateFieldTemplate(field);
88 | }).join(',\n ')
89 | : '';
90 |
91 | // Generate admin code
92 | const adminCode = Object.keys(admin).length > 0
93 | ? `\n admin: {
94 | ${admin.useAsTitle ? `useAsTitle: '${admin.useAsTitle}',` : ''}
95 | ${admin.defaultColumns ? `defaultColumns: [${admin.defaultColumns.map((col: string) => `'${col}'`).join(', ')}],` : ''}
96 | ${admin.group ? `group: '${admin.group}',` : ''}
97 | },`
98 | : '';
99 |
100 | // Generate hooks code
101 | const hooksCode = hooks
102 | ? `\n hooks: {
103 | beforeOperation: [
104 | // Add your hooks here
105 | ],
106 | afterOperation: [
107 | // Add your hooks here
108 | ],
109 | },`
110 | : '';
111 |
112 | // Generate access code
113 | const accessCode = access
114 | ? `\n access: {
115 | read: () => true,
116 | update: () => true,
117 | create: () => true,
118 | delete: () => true,
119 | },`
120 | : '';
121 |
122 | // Generate auth code
123 | const authCode = auth
124 | ? `\n auth: {
125 | useAPIKey: true,
126 | tokenExpiration: 7200,
127 | },`
128 | : '';
129 |
130 | // Generate versions code
131 | const versionsCode = versions
132 | ? `\n versions: {
133 | drafts: true,
134 | },`
135 | : '';
136 |
137 | return `import { CollectionConfig } from 'payload/types';
138 |
139 | const ${slug.charAt(0).toUpperCase() + slug.slice(1)}: CollectionConfig = {
140 | slug: '${slug}',${adminCode}${authCode}${accessCode}${hooksCode}${versionsCode}
141 | ${timestamps ? 'timestamps: true,' : ''}
142 | fields: [
143 | ${fieldsCode}
144 | ],
145 | };
146 |
147 | export default ${slug.charAt(0).toUpperCase() + slug.slice(1)};`;
148 | }
149 |
150 | /**
151 | * Generate a field template for Payload CMS 3
152 | * @param options Field options
153 | * @returns The generated field code
154 | */
155 | function generateFieldTemplate(options: Record): string {
156 | const {
157 | name,
158 | type,
159 | required = false,
160 | unique = false,
161 | localized = false,
162 | access = false,
163 | admin = {},
164 | validation = false,
165 | defaultValue,
166 | } = options;
167 |
168 | if (!name || !type) {
169 | throw new Error('Field name and type are required');
170 | }
171 |
172 | // Generate admin code
173 | const adminCode = Object.keys(admin).length > 0
174 | ? `\n admin: {
175 | ${admin.description ? `description: '${admin.description}',` : ''}
176 | ${admin.readOnly ? 'readOnly: true,' : ''}
177 | },`
178 | : '';
179 |
180 | // Generate access code
181 | const accessCode = access
182 | ? `\n access: {
183 | read: () => true,
184 | update: () => true,
185 | },`
186 | : '';
187 |
188 | // Generate validation code
189 | const validationCode = validation
190 | ? `\n validate: (value) => {
191 | if (value === undefined || value === null) {
192 | return '${name} is required';
193 | }
194 | return true;
195 | },`
196 | : '';
197 |
198 | // Generate default value code
199 | const defaultValueCode = defaultValue !== undefined
200 | ? `\n defaultValue: ${typeof defaultValue === 'string' ? `'${defaultValue}'` : defaultValue},`
201 | : '';
202 |
203 | // Generate field-specific options based on type
204 | let fieldSpecificOptions = '';
205 |
206 | switch (type) {
207 | case 'text':
208 | case 'textarea':
209 | case 'email':
210 | case 'code':
211 | fieldSpecificOptions = `\n minLength: 1,
212 | maxLength: 255,`;
213 | break;
214 | case 'number':
215 | fieldSpecificOptions = `\n min: 0,
216 | max: 1000,`;
217 | break;
218 | case 'select':
219 | fieldSpecificOptions = `\n options: [
220 | { label: 'Option 1', value: 'option1' },
221 | { label: 'Option 2', value: 'option2' },
222 | ],
223 | hasMany: false,`;
224 | break;
225 | case 'relationship':
226 | fieldSpecificOptions = `\n relationTo: 'collection-name',
227 | hasMany: false,`;
228 | break;
229 | case 'array':
230 | fieldSpecificOptions = `\n minRows: 0,
231 | maxRows: 10,
232 | fields: [
233 | {
234 | name: 'subField',
235 | type: 'text',
236 | required: true,
237 | },
238 | ],`;
239 | break;
240 | case 'blocks':
241 | fieldSpecificOptions = `\n blocks: [
242 | {
243 | slug: 'block-name',
244 | fields: [
245 | {
246 | name: 'blockField',
247 | type: 'text',
248 | required: true,
249 | },
250 | ],
251 | },
252 | ],`;
253 | break;
254 | }
255 |
256 | return `{
257 | name: '${name}',
258 | type: '${type}',${required ? '\n required: true,' : ''}${unique ? '\n unique: true,' : ''}${localized ? '\n localized: true,' : ''}${adminCode}${accessCode}${validationCode}${defaultValueCode}${fieldSpecificOptions}
259 | }`;
260 | }
261 |
262 | /**
263 | * Generate a global template for Payload CMS 3
264 | * @param options Global options
265 | * @returns The generated global code
266 | */
267 | function generateGlobalTemplate(options: Record): string {
268 | const {
269 | slug,
270 | fields = [],
271 | admin = {},
272 | access = false,
273 | versions = false,
274 | } = options;
275 |
276 | if (!slug) {
277 | throw new Error('Global slug is required');
278 | }
279 |
280 | // Generate fields code
281 | const fieldsCode = fields.length > 0
282 | ? fields.map((field: any) => {
283 | return generateFieldTemplate(field);
284 | }).join(',\n ')
285 | : '';
286 |
287 | // Generate admin code
288 | const adminCode = Object.keys(admin).length > 0
289 | ? `\n admin: {
290 | ${admin.group ? `group: '${admin.group}',` : ''}
291 | },`
292 | : '';
293 |
294 | // Generate access code
295 | const accessCode = access
296 | ? `\n access: {
297 | read: () => true,
298 | update: () => true,
299 | },`
300 | : '';
301 |
302 | // Generate versions code
303 | const versionsCode = versions
304 | ? `\n versions: {
305 | drafts: true,
306 | },`
307 | : '';
308 |
309 | return `import { GlobalConfig } from 'payload/types';
310 |
311 | const ${slug.charAt(0).toUpperCase() + slug.slice(1)}: GlobalConfig = {
312 | slug: '${slug}',${adminCode}${accessCode}${versionsCode}
313 | fields: [
314 | ${fieldsCode}
315 | ],
316 | };
317 |
318 | export default ${slug.charAt(0).toUpperCase() + slug.slice(1)};`;
319 | }
320 |
321 | /**
322 | * Generate a config template for Payload CMS 3
323 | * @param options Config options
324 | * @returns The generated config code
325 | */
326 | function generateConfigTemplate(options: Record): string {
327 | const {
328 | serverURL = 'http://localhost:3000',
329 | collections = [],
330 | globals = [],
331 | admin = {},
332 | db = 'mongodb',
333 | plugins = [],
334 | typescript = true,
335 | } = options;
336 |
337 | // Generate collections code
338 | const collectionsCode = collections.length > 0
339 | ? collections.map((collection: string) => `import ${collection.charAt(0).toUpperCase() + collection.slice(1)} from './collections/${collection}';`).join('\n')
340 | : '';
341 |
342 | // Generate globals code
343 | const globalsCode = globals.length > 0
344 | ? globals.map((global: string) => `import ${global.charAt(0).toUpperCase() + global.slice(1)} from './globals/${global}';`).join('\n')
345 | : '';
346 |
347 | // Generate plugins code
348 | const pluginsCode = plugins.length > 0
349 | ? plugins.map((plugin: string) => {
350 | if (plugin === 'form-builder') {
351 | return `import formBuilder from '@payloadcms/plugin-form-builder';`;
352 | } else if (plugin === 'seo') {
353 | return `import seoPlugin from '@payloadcms/plugin-seo';`;
354 | } else if (plugin === 'nested-docs') {
355 | return `import nestedDocs from '@payloadcms/plugin-nested-docs';`;
356 | } else {
357 | return `import ${plugin} from '@payloadcms/plugin-${plugin}';`;
358 | }
359 | }).join('\n')
360 | : '';
361 |
362 | // Generate plugins initialization code
363 | const pluginsInitCode = plugins.length > 0
364 | ? `\n plugins: [
365 | ${plugins.map((plugin: string) => {
366 | if (plugin === 'form-builder') {
367 | return `formBuilder({
368 | formOverrides: {
369 | admin: {
370 | group: 'Content',
371 | },
372 | },
373 | formSubmissionOverrides: {
374 | admin: {
375 | group: 'Content',
376 | },
377 | },
378 | redirectRelationships: ['pages'],
379 | }),`;
380 | } else if (plugin === 'seo') {
381 | return `seoPlugin(),`;
382 | } else if (plugin === 'nested-docs') {
383 | return `nestedDocs({
384 | collections: ['pages'],
385 | }),`;
386 | } else {
387 | return `${plugin}(),`;
388 | }
389 | }).join('\n ')}
390 | ],`
391 | : '';
392 |
393 | // Generate admin code
394 | const adminInitCode = Object.keys(admin).length > 0
395 | ? `\n admin: {
396 | user: '${admin.user || 'users'}',
397 | bundler: ${admin.bundler === 'vite' ? 'viteBundler()' : 'webpackBundler()'},
398 | meta: {
399 | titleSuffix: '- Payload CMS',
400 | favicon: '/assets/favicon.ico',
401 | ogImage: '/assets/og-image.jpg',
402 | },
403 | },`
404 | : '';
405 |
406 | // Generate database code
407 | const dbCode = db === 'postgres'
408 | ? `\n db: postgresAdapter({
409 | pool: {
410 | connectionString: process.env.DATABASE_URI,
411 | },
412 | }),`
413 | : `\n db: mongooseAdapter({
414 | url: process.env.MONGODB_URI,
415 | }),`;
416 |
417 | // Generate collections and globals initialization
418 | const collectionsInitCode = collections.length > 0
419 | ? `\n collections: [
420 | ${collections.map((collection: string) => `${collection.charAt(0).toUpperCase() + collection.slice(1)},`).join('\n ')}
421 | ],`
422 | : '';
423 |
424 | const globalsInitCode = globals.length > 0
425 | ? `\n globals: [
426 | ${globals.map((global: string) => `${global.charAt(0).toUpperCase() + global.slice(1)},`).join('\n ')}
427 | ],`
428 | : '';
429 |
430 | // Generate imports for database adapters
431 | const dbImports = db === 'postgres'
432 | ? `import { postgresAdapter } from '@payloadcms/db-postgres';`
433 | : `import { mongooseAdapter } from '@payloadcms/db-mongoose';`;
434 |
435 | // Generate bundler imports
436 | const bundlerImports = admin.bundler === 'vite'
437 | ? `import { viteBundler } from '@payloadcms/bundler-vite';`
438 | : `import { webpackBundler } from '@payloadcms/bundler-webpack';`;
439 |
440 | return `import path from 'path';
441 | import { buildConfig } from 'payload/config';
442 | ${dbImports}
443 | ${bundlerImports}
444 | ${collectionsCode ? `\n${collectionsCode}` : ''}
445 | ${globalsCode ? `\n${globalsCode}` : ''}
446 | ${pluginsCode ? `\n${pluginsCode}` : ''}
447 |
448 | export default buildConfig({
449 | serverURL: '${serverURL}',${adminInitCode}${dbCode}${pluginsInitCode}${collectionsInitCode}${globalsInitCode}
450 | typescript: {
451 | outputFile: path.resolve(__dirname, 'payload-types.ts'),
452 | },
453 | graphQL: {
454 | schemaOutputFile: path.resolve(__dirname, 'generated-schema.graphql'),
455 | },
456 | cors: ['http://localhost:3000'],
457 | csrf: [
458 | 'http://localhost:3000',
459 | ],
460 | });`;
461 | }
462 |
463 | /**
464 | * Generate an access control template for Payload CMS 3
465 | * @param options Access control options
466 | * @returns The generated access control code
467 | */
468 | function generateAccessControlTemplate(options: Record): string {
469 | const {
470 | type = 'collection',
471 | name = 'default',
472 | roles = ['admin', 'editor', 'user'],
473 | } = options;
474 |
475 | return `import { Access } from 'payload/types';
476 |
477 | // Define user roles type
478 | type Role = ${roles.map(role => `'${role}'`).join(' | ')};
479 |
480 | // Access control for ${type} ${name}
481 | export const ${name}Access: Access = ({ req }) => {
482 | // If there's no user, deny access
483 | if (!req.user) {
484 | return false;
485 | }
486 |
487 | // Admin users can do anything
488 | if (req.user.role === 'admin') {
489 | return true;
490 | }
491 |
492 | // Editor users can read and update but not delete
493 | if (req.user.role === 'editor') {
494 | return {
495 | read: true,
496 | update: true,
497 | create: true,
498 | delete: false,
499 | };
500 | }
501 |
502 | // Regular users can only read their own documents
503 | if (req.user.role === 'user') {
504 | return {
505 | read: {
506 | and: [
507 | {
508 | createdBy: {
509 | equals: req.user.id,
510 | },
511 | },
512 | ],
513 | },
514 | update: {
515 | createdBy: {
516 | equals: req.user.id,
517 | },
518 | },
519 | create: true,
520 | delete: {
521 | createdBy: {
522 | equals: req.user.id,
523 | },
524 | },
525 | };
526 | }
527 |
528 | // Default deny
529 | return false;
530 | };`;
531 | }
532 |
533 | /**
534 | * Generate a hook template for Payload CMS 3
535 | * @param options Hook options
536 | * @returns The generated hook code
537 | */
538 | function generateHookTemplate(options: Record): string {
539 | const {
540 | type = 'collection',
541 | name = 'default',
542 | operation = 'create',
543 | timing = 'before',
544 | } = options;
545 |
546 | return `import { ${timing === 'before' ? 'BeforeOperation' : 'AfterOperation'} } from 'payload/types';
547 |
548 | // ${timing}${operation.charAt(0).toUpperCase() + operation.slice(1)} hook for ${type} ${name}
549 | export const ${timing}${operation.charAt(0).toUpperCase() + operation.slice(1)}Hook: ${timing === 'before' ? 'BeforeOperation' : 'AfterOperation'} = async ({
550 | req,
551 | data,
552 | operation,
553 | ${timing === 'after' ? 'doc,' : ''}
554 | ${timing === 'after' ? 'previousDoc,' : ''}
555 | }) => {
556 | // Your hook logic here
557 | console.log(\`${timing} ${operation} operation on ${type} ${name}\`);
558 |
559 | ${timing === 'before'
560 | ? `// You can modify the data before it's saved
561 | return data;`
562 | : `// You can perform actions after the operation
563 | return doc;`}
564 | };`;
565 | }
566 |
567 | /**
568 | * Generate an endpoint template for Payload CMS 3
569 | * @param options Endpoint options
570 | * @returns The generated endpoint code
571 | */
572 | function generateEndpointTemplate(options: Record): string {
573 | const {
574 | path = '/api/custom',
575 | method = 'get',
576 | auth = true,
577 | } = options;
578 |
579 | return `import { Payload } from 'payload';
580 | import { Request, Response } from 'express';
581 |
582 | // Custom endpoint handler
583 | export const ${method}${path.replace(/\//g, '_').replace(/^_/, '').replace(/_$/, '')} = async (req: Request, res: Response, payload: Payload) => {
584 | try {
585 | ${auth ? `// Check if user is authenticated
586 | if (!req.user) {
587 | return res.status(401).json({
588 | message: 'Unauthorized',
589 | });
590 | }` : ''}
591 |
592 | // Your endpoint logic here
593 | const result = {
594 | message: 'Success',
595 | timestamp: new Date().toISOString(),
596 | };
597 |
598 | // Return successful response
599 | return res.status(200).json(result);
600 | } catch (error) {
601 | // Handle errors
602 | console.error(\`Error in ${path} endpoint:\`, error);
603 | return res.status(500).json({
604 | message: 'Internal Server Error',
605 | error: error.message,
606 | });
607 | }
608 | };
609 |
610 | // Endpoint configuration
611 | export default {
612 | path: '${path}',
613 | method: '${method}',
614 | handler: ${method}${path.replace(/\//g, '_').replace(/^_/, '').replace(/_$/, '')},
615 | };`;
616 | }
617 |
618 | /**
619 | * Generate a plugin template for Payload CMS 3
620 | * @param options Plugin options
621 | * @returns The generated plugin code
622 | */
623 | function generatePluginTemplate(options: Record): string {
624 | const {
625 | name = 'custom-plugin',
626 | collections = [],
627 | globals = [],
628 | fields = [],
629 | endpoints = [],
630 | } = options;
631 |
632 | return `import { Config, Plugin } from 'payload/config';
633 |
634 | // Define the plugin options type
635 | export interface ${name.replace(/-/g, '_').charAt(0).toUpperCase() + name.replace(/-/g, '_').slice(1)}PluginOptions {
636 | // Add your plugin options here
637 | enabled?: boolean;
638 | }
639 |
640 | // Define the plugin
641 | export const ${name.replace(/-/g, '_')}Plugin = (options: ${name.replace(/-/g, '_').charAt(0).toUpperCase() + name.replace(/-/g, '_').slice(1)}PluginOptions = {}): Plugin => {
642 | return {
643 | // Plugin name
644 | name: '${name}',
645 |
646 | // Plugin configuration function
647 | config: (incomingConfig: Config): Config => {
648 | // Default options
649 | const { enabled = true } = options;
650 |
651 | if (!enabled) {
652 | return incomingConfig;
653 | }
654 |
655 | // Create a new config to modify
656 | const config = { ...incomingConfig };
657 |
658 | // Add collections
659 | ${collections.length > 0 ? `
660 | // Add plugin collections
661 | const collections = [
662 | // Define your collections here
663 | ${collections.map((collection: string) => `{
664 | slug: '${collection}',
665 | // Add collection configuration
666 | }`).join(',\n ')}
667 | ];
668 |
669 | config.collections = [
670 | ...(config.collections || []),
671 | ...collections,
672 | ];` : '// No collections to add'}
673 |
674 | // Add globals
675 | ${globals.length > 0 ? `
676 | // Add plugin globals
677 | const globals = [
678 | // Define your globals here
679 | ${globals.map((global: string) => `{
680 | slug: '${global}',
681 | // Add global configuration
682 | }`).join(',\n ')}
683 | ];
684 |
685 | config.globals = [
686 | ...(config.globals || []),
687 | ...globals,
688 | ];` : '// No globals to add'}
689 |
690 | // Add endpoints
691 | ${endpoints.length > 0 ? `
692 | // Add plugin endpoints
693 | const endpoints = [
694 | // Define your endpoints here
695 | ${endpoints.map((endpoint: string) => `{
696 | path: '/${endpoint}',
697 | method: 'get',
698 | handler: async (req, res) => {
699 | res.status(200).json({ message: '${endpoint} endpoint' });
700 | },
701 | }`).join(',\n ')}
702 | ];
703 |
704 | config.endpoints = [
705 | ...(config.endpoints || []),
706 | ...endpoints,
707 | ];` : '// No endpoints to add'}
708 |
709 | // Return the modified config
710 | return config;
711 | },
712 | };
713 | };
714 |
715 | export default ${name.replace(/-/g, '_')}Plugin;`;
716 | }
717 |
718 | /**
719 | * Generate a block template for Payload CMS 3
720 | * @param options Block options
721 | * @returns The generated block code
722 | */
723 | function generateBlockTemplate(options: Record): string {
724 | const {
725 | name = 'custom-block',
726 | fields = [],
727 | imageField = true,
728 | contentField = true,
729 | } = options;
730 |
731 | // Generate fields code
732 | const fieldsCode = fields.length > 0
733 | ? fields.map((field: any) => {
734 | return generateFieldTemplate(field);
735 | }).join(',\n ')
736 | : '';
737 |
738 | // Generate image field
739 | const imageFieldCode = imageField
740 | ? `{
741 | name: 'image',
742 | type: 'upload',
743 | relationTo: 'media',
744 | required: true,
745 | admin: {
746 | description: 'Add an image to this block',
747 | },
748 | },`
749 | : '';
750 |
751 | // Generate content field
752 | const contentFieldCode = contentField
753 | ? `{
754 | name: 'content',
755 | type: 'richText',
756 | required: true,
757 | admin: {
758 | description: 'Add content to this block',
759 | },
760 | },`
761 | : '';
762 |
763 | return `import { Block } from 'payload/types';
764 |
765 | // Define the ${name} block
766 | export const ${name.replace(/-/g, '_')}Block: Block = {
767 | slug: '${name}',
768 | labels: {
769 | singular: '${name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, ' ')}',
770 | plural: '${name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, ' ')}s',
771 | },
772 | fields: [
773 | ${imageFieldCode}
774 | ${contentFieldCode}
775 | ${fieldsCode}
776 | ],
777 | };
778 |
779 | export default ${name.replace(/-/g, '_')}Block;`;
780 | }
781 |
782 | /**
783 | * Generate a migration template for Payload CMS 3
784 | * @param options Migration options
785 | * @returns The generated migration code
786 | */
787 | function generateMigrationTemplate(options: Record): string {
788 | const {
789 | name = 'custom-migration',
790 | collection = '',
791 | operation = 'update',
792 | } = options;
793 |
794 | return `import { Payload } from 'payload';
795 |
796 | // Migration: ${name}
797 | export const ${name.replace(/-/g, '_')}Migration = async (payload: Payload) => {
798 | try {
799 | console.log('Starting migration: ${name}');
800 |
801 | ${collection ? `// Get the collection
802 | const collection = '${collection}';
803 |
804 | // Find documents to migrate
805 | const docs = await payload.find({
806 | collection,
807 | limit: 100,
808 | });
809 |
810 | console.log(\`Found \${docs.docs.length} documents to migrate\`);
811 |
812 | // Process each document
813 | for (const doc of docs.docs) {
814 | ${operation === 'update' ? `// Update the document
815 | await payload.update({
816 | collection,
817 | id: doc.id,
818 | data: {
819 | // Add your migration changes here
820 | migratedAt: new Date().toISOString(),
821 | },
822 | });` : operation === 'delete' ? `// Delete the document
823 | await payload.delete({
824 | collection,
825 | id: doc.id,
826 | });` : `// Custom operation
827 | // Add your custom migration logic here`}
828 | }` : `// Add your migration logic here
829 | // This could be schema changes, data transformations, etc.`}
830 |
831 | console.log('Migration completed successfully: ${name}');
832 | return { success: true };
833 | } catch (error) {
834 | console.error('Migration failed:', error);
835 | return { success: false, error: error.message };
836 | }
837 | };
838 |
839 | export default ${name.replace(/-/g, '_')}Migration;`;
840 | }
--------------------------------------------------------------------------------