diff --git a/ecosystem.mcps.config.cjs b/ecosystem.mcps.config.cjs index c8e91d2..a7526e0 100644 --- a/ecosystem.mcps.config.cjs +++ b/ecosystem.mcps.config.cjs @@ -35,6 +35,14 @@ module.exports = { cwd: './packages/glitchtip-mcp', watch: false, }, + { + name: 'klz-payload-mcp', + script: 'node', + args: 'dist/start.js', + cwd: './packages/klz-payload-mcp', + watch: false, + }, ] }; + diff --git a/packages/klz-payload-mcp/package.json b/packages/klz-payload-mcp/package.json new file mode 100644 index 0000000..025f113 --- /dev/null +++ b/packages/klz-payload-mcp/package.json @@ -0,0 +1,25 @@ +{ + "name": "@mintel/klz-payload-mcp", + "version": "1.9.10", + "description": "KLZ PayloadCMS MCP server for technical product data and leads", + "main": "dist/index.js", + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/start.js", + "dev": "tsx watch src/index.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", + "axios": "^1.7.2", + "dotenv": "^17.3.1", + "express": "^5.2.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^20.14.10", + "typescript": "^5.5.3", + "tsx": "^4.19.2" + } +} \ No newline at end of file diff --git a/packages/klz-payload-mcp/src/index.ts b/packages/klz-payload-mcp/src/index.ts new file mode 100644 index 0000000..217d646 --- /dev/null +++ b/packages/klz-payload-mcp/src/index.ts @@ -0,0 +1,137 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import express, { Request, Response } from 'express'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import axios from "axios"; +import https from "https"; + +const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://klz.infra.mintel.me"; +const PAYLOAD_API_KEY = process.env.PAYLOAD_API_KEY; + +if (!PAYLOAD_API_KEY) { + console.error("Warning: PAYLOAD_API_KEY is not set. API calls will fail."); +} + +const httpsAgent = new https.Agent({ + rejectUnauthorized: false, // For internal infra +}); + +const payloadClient = axios.create({ + baseURL: `${PAYLOAD_URL}/api`, + headers: { Authorization: `users API-Key ${PAYLOAD_API_KEY}` }, + httpsAgent +}); + +const SEARCH_PRODUCTS_TOOL: Tool = { + name: "payload_search_products", + description: "Search for technical product specifications (cables, cross-sections) in KLZ Payload CMS", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Search query or part number" }, + limit: { type: "number", description: "Maximum number of results" }, + }, + }, +}; + +const LIST_LEADS_TOOL: Tool = { + name: "payload_list_leads", + description: "List recent lead inquiries and contact requests", + inputSchema: { + type: "object", + properties: { + limit: { type: "number", description: "Maximum number of leads" }, + }, + }, +}; + +const server = new Server( + { name: "klz-payload-mcp", version: "1.0.0" }, + { capabilities: { tools: {} } } +); + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + SEARCH_PRODUCTS_TOOL, + LIST_LEADS_TOOL, + ], +})); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === "payload_search_products") { + const { query, limit = 10 } = request.params.arguments as any; + try { + const res = await payloadClient.get('/products', { + params: { + where: query ? { + or: [ + { title: { contains: query } }, + { slug: { contains: query } }, + { description: { contains: query } } + ] + } : {}, + limit + } + }); + return { content: [{ type: "text", text: JSON.stringify(res.data.docs, null, 2) }] }; + } catch (e: any) { + return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; + } + } + + if (request.params.name === "payload_list_leads") { + const { limit = 10 } = request.params.arguments as any; + try { + const res = await payloadClient.get('/leads', { + params: { limit, sort: '-createdAt' } + }); + return { content: [{ type: "text", text: JSON.stringify(res.data.docs, null, 2) }] }; + } catch (e: any) { + return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; + } + } + + throw new Error(`Unknown tool: ${request.params.name}`); +}); + +async function run() { + const isStdio = process.argv.includes('--stdio'); + + if (isStdio) { + const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js'); + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('KLZ Payload MCP server is running on stdio'); + } else { + const app = express(); + let transport: SSEServerTransport | null = null; + + app.get('/sse', async (req: Request, res: Response) => { + console.error('New SSE connection established'); + transport = new SSEServerTransport('/message', res); + await server.connect(transport); + }); + + app.post('/message', async (req: Request, res: Response) => { + if (!transport) { + res.status(400).send('No active SSE connection'); + return; + } + await transport.handlePostMessage(req, res); + }); + + const PORT = process.env.KLZ_PAYLOAD_MCP_PORT || 3006; + app.listen(PORT, () => { + console.error(`KLZ Payload MCP server running on http://localhost:${PORT}/sse`); + }); + } +} + +run().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/packages/klz-payload-mcp/src/start.ts b/packages/klz-payload-mcp/src/start.ts new file mode 100644 index 0000000..0034613 --- /dev/null +++ b/packages/klz-payload-mcp/src/start.ts @@ -0,0 +1,13 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +config({ path: resolve(__dirname, '../../../.env.local') }); +config({ path: resolve(__dirname, '../../../.env') }); + +import('./index.js').catch(err => { + console.error('Failed to start KLZ Payload MCP Server:', err); + process.exit(1); +}); diff --git a/packages/klz-payload-mcp/tsconfig.json b/packages/klz-payload-mcp/tsconfig.json new file mode 100644 index 0000000..0e88912 --- /dev/null +++ b/packages/klz-payload-mcp/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "src/**/*" + ] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f8de54..e4d774f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -483,6 +483,37 @@ importers: specifier: ^5.0.0 version: 5.9.3 + packages/klz-payload-mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.27.1 + version: 1.27.1(zod@3.25.76) + axios: + specifier: ^1.7.2 + version: 1.13.5 + dotenv: + specifier: ^17.3.1 + version: 17.3.1 + express: + specifier: ^5.2.1 + version: 5.2.1 + zod: + specifier: ^3.23.8 + version: 3.25.76 + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^20.14.10 + version: 20.19.33 + tsx: + specifier: ^4.19.2 + version: 4.21.0 + typescript: + specifier: ^5.5.3 + version: 5.9.3 + packages/mail: dependencies: '@react-email/components':