├── .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 | /Payload CMS 3.0 Logo/g, 17 | 'Matmax Payload CMS MCP Server' 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 | Matmax Logo 77 |
78 | PAYLOAD CMS 79 | MCP SERVER 80 |
81 |
82 | 83 | 84 | 85 |
86 | 87 | 88 |
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 | Payload CMS Logo 6 |

7 |

8 | MCP Enabled 9 | Payload CMS 10 | License 11 | Railway Deployment 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 | 36 | 41 | 46 | 47 |
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 |
37 |

🔍

38 | Code Generation 39 |

Generate code templates for collections, fields, globals, access control, hooks, endpoints, plugins, blocks, and migrations.

40 |
42 |

🚀

43 | Project Scaffolding 44 |

Scaffold entire Payload CMS projects with validated options for consistency and adherence to best practices.

45 |
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 | [![npm version](https://img.shields.io/npm/v/payload-cms-mcp.svg?style=flat-square)](https://www.npmjs.org/package/payload-cms-mcp) 651 | [![npm downloads](https://img.shields.io/npm/dm/payload-cms-mcp.svg?style=flat-square)](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 | [![Deploy on Railway](https://railway.app/button.svg)](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 |
473 |
474 | 478 | 486 |
487 |
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 |
495 | View on GitHub 496 | Explore API 497 |
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 |
541 | /sse 542 | GET 543 |
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 |
552 | /api/sse 553 | GET 554 |
555 |
556 | Alternative SSE endpoint for API-based communication. 557 |
558 |
https://www.payloadcmsmcp.info/api/sse
559 |
560 | 561 |
562 |
563 | /api/validate 564 | POST 565 |
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 |
574 | /api/query 575 | POST 576 |
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 | MATMAX WORLDWIDE - Technology for humanity 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 | 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 | } --------------------------------------------------------------------------------