From 0b929d00a147c0ce39f14ad1beb3a9fa48f2064c Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 13 Apr 2026 12:30:31 +0200 Subject: [PATCH] fix(next-config): move serverActions to experimental for Next.js 15+ compatibility --- .env | 12 ++ packages/gitea-mcp/package.json | 4 +- packages/gitea-mcp/src/index.ts | 44 ++++-- packages/glitchtip-mcp/package.json | 4 +- packages/glitchtip-mcp/src/index.ts | 96 ++++++++---- packages/klz-payload-mcp/package.json | 6 +- packages/klz-payload-mcp/src/index.ts | 36 +++-- packages/memory-mcp/package.json | 4 +- packages/memory-mcp/src/index.ts | 39 ++++- packages/next-config/index.js | 6 +- packages/payload-ai/package.json | 2 +- packages/payload-ai/src/tools/mcpAdapter.ts | 8 +- packages/serpbear-mcp/package.json | 4 +- packages/serpbear-mcp/src/index.ts | 112 +++++++------ packages/umami-mcp/package.json | 4 +- packages/umami-mcp/src/index.ts | 164 +++++++++++--------- pnpm-lock.yaml | 105 ------------- 17 files changed, 343 insertions(+), 307 deletions(-) diff --git a/.env b/.env index 4e85276..893033c 100644 --- a/.env +++ b/.env @@ -47,3 +47,15 @@ SENTRY_DSN= # Analytics (Umami) NEXT_PUBLIC_UMAMI_WEBSITE_ID= NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js +UMAMI_USERNAME=mmintel +UMAMI_PASSWORD=Tim300493. + +# MCP Services +QDRANT_URL=http://qdrant:6333 +SERPBEAR_API_KEY=Tim300493. +GLITCHTIP_API_KEY=3d37db66fa1fc3a1cdcd1357728b056225aca950f0163b50c5e922344807ede9 + +# Corrected MCP Base URLs +UMAMI_BASE_URL=https://analytics.infra.mintel.me +GLITCHTIP_BASE_URL=https://errors.infra.mintel.me +SERPBEAR_BASE_URL=https://seo.infra.mintel.me diff --git a/packages/gitea-mcp/package.json b/packages/gitea-mcp/package.json index a07023c..db03fdb 100644 --- a/packages/gitea-mcp/package.json +++ b/packages/gitea-mcp/package.json @@ -12,10 +12,10 @@ "@modelcontextprotocol/sdk": "^1.5.0", "axios": "^1.7.2", "dotenv": "^17.3.1", - "express": "^5.2.1" + "express": "^4.19.2" }, "devDependencies": { - "@types/express": "^5.0.6", + "@types/express": "^4.17.21", "@types/node": "^20.14.10", "typescript": "^5.5.3" } diff --git a/packages/gitea-mcp/src/index.ts b/packages/gitea-mcp/src/index.ts index 761c447..ae6932f 100644 --- a/packages/gitea-mcp/src/index.ts +++ b/packages/gitea-mcp/src/index.ts @@ -26,8 +26,9 @@ if (!GITEA_ACCESS_TOKEN) { const giteaClient = axios.create({ baseURL: `${GITEA_HOST.replace(/\/$/, "")}/api/v1`, headers: { - Authorization: `token ${GITEA_ACCESS_TOKEN}`, + Authorization: `token ${GITEA_ACCESS_TOKEN || ''}`, }, + timeout: 10000 }); const LIST_PIPELINES_TOOL: Tool = { @@ -1275,6 +1276,18 @@ async function run() { const app = express(); const transports = new Map(); + // Manual CORS middleware + app.use((req, res, next) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, x-mcp-protocol-version"); + if (req.method === "OPTIONS") { + res.status(204).end(); + return; + } + next(); + }); + // Middleware to log all requests for debugging app.use((req, _res, next) => { console.error(`${req.method} ${req.url}`); @@ -1282,34 +1295,45 @@ async function run() { }); app.get("/sse", async (req, res) => { - const sessionId = crypto.randomUUID(); - console.error(`New SSE connection: ${sessionId}`); - const transport = new SSEServerTransport(`/message/${sessionId}`, res); + const transport = new SSEServerTransport("/message", res as any); + const sessionId = transport.sessionId; transports.set(sessionId, transport); + console.error(`New SSE connection: ${sessionId}`); + + const heartbeatInterval = setInterval(() => { + res.write(": heartbeat\n\n"); + }, 15000); req.on("close", () => { console.error(`SSE connection closed: ${sessionId}`); + clearInterval(heartbeatInterval); transports.delete(sessionId); }); await server.connect(transport); + + // Keep the connection open indefinitely until client closes + await new Promise((resolve) => { + req.on("close", resolve); + }); }); - app.post("/message/:sessionId", async (req, res) => { - const { sessionId } = req.params; + app.post("/message", express.json(), async (req, res) => { + const sessionId = req.query.sessionId as string; const transport = transports.get(sessionId); if (!transport) { console.error(`No transport found for session: ${sessionId}`); - res.status(400).send("No active SSE connection for this session"); + res.status(404).send("No active SSE connection for this session"); return; } + await transport.handlePostMessage(req, res); }); - const PORT = process.env.GITEA_MCP_PORT || 3001; - app.listen(PORT, () => { - console.error(`Gitea MCP server running on http://localhost:${PORT}/sse`); + const PORT = Number(process.env.GITEA_MCP_PORT) || 3001; + app.listen(PORT, "0.0.0.0", () => { + console.error(`Gitea MCP server running on http://0.0.0.0:${PORT}/sse`); }); // Start the background poller only in SSE mode or if specifically desired diff --git a/packages/glitchtip-mcp/package.json b/packages/glitchtip-mcp/package.json index 047678b..9a5280c 100644 --- a/packages/glitchtip-mcp/package.json +++ b/packages/glitchtip-mcp/package.json @@ -13,10 +13,10 @@ "@modelcontextprotocol/sdk": "^1.5.0", "axios": "^1.7.2", "dotenv": "^17.3.1", - "express": "^5.2.1" + "express": "^4.19.2" }, "devDependencies": { - "@types/express": "^5.0.6", + "@types/express": "^4.17.21", "@types/node": "^20.14.10", "tsx": "^4.19.2", "typescript": "^5.5.3" diff --git a/packages/glitchtip-mcp/src/index.ts b/packages/glitchtip-mcp/src/index.ts index 1f5b8f0..59d5c0d 100644 --- a/packages/glitchtip-mcp/src/index.ts +++ b/packages/glitchtip-mcp/src/index.ts @@ -24,7 +24,25 @@ const httpsAgent = new https.Agent({ const glitchtipClient = axios.create({ baseURL: `${GLITCHTIP_BASE_URL}/api/0`, headers: { Authorization: `Bearer ${GLITCHTIP_API_KEY}` }, - httpsAgent + httpsAgent, + timeout: 10000 // Add timeout to prevent hangs +}); + +// Logging middleware for GlitchTip client +glitchtipClient.interceptors.request.use((config) => { + console.error(`GlitchTip Request: ${config.method?.toUpperCase()} ${config.url}`); + return config; +}, (error) => { + console.error(`GlitchTip Request Error: ${error.message}`); + return Promise.reject(error); +}); + +glitchtipClient.interceptors.response.use((response) => { + return response; +}, (error) => { + const msg = error.response?.data?.detail || error.response?.data?.message || error.message; + console.error(`GlitchTip API Error (${error.config?.url}): ${msg}`); + return Promise.reject(error); }); const LIST_PROJECTS_TOOL: Tool = { @@ -88,48 +106,37 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ })); server.setRequestHandler(CallToolRequestSchema, async (request) => { - if (request.params.name === "glitchtip_list_projects") { - try { + try { + if (request.params.name === "glitchtip_list_projects") { const res = await glitchtipClient.get('/projects/'); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; - } catch (e: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; } - } - if (request.params.name === "glitchtip_list_issues") { - const { organization_slug, project_slug, query, limit = 20 } = request.params.arguments as any; - try { + if (request.params.name === "glitchtip_list_issues") { + const { organization_slug, project_slug, query, limit = 20 } = request.params.arguments as any; const res = await glitchtipClient.get(`/projects/${organization_slug}/${project_slug}/issues/`, { params: { query, limit } }); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; - } catch (e: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; } - } - if (request.params.name === "glitchtip_get_issue_details") { - const { issue_id } = request.params.arguments as any; - try { + if (request.params.name === "glitchtip_get_issue_details") { + const { issue_id } = request.params.arguments as any; const res = await glitchtipClient.get(`/issues/${issue_id}/`); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; - } catch (e: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; } - } - if (request.params.name === "glitchtip_update_issue") { - const { issue_id, status } = request.params.arguments as any; - try { + if (request.params.name === "glitchtip_update_issue") { + const { issue_id, status } = request.params.arguments as any; const res = await glitchtipClient.put(`/issues/${issue_id}/`, { status }); return { content: [{ type: "text", text: `Issue ${issue_id} status updated to ${status}.` }] }; - } catch (e: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; } - } - throw new Error(`Unknown tool: ${request.params.name}`); + throw new Error(`Unknown tool: ${request.params.name}`); + } catch (e: any) { + const msg = e.response?.data?.detail || e.response?.data?.message || e.message; + return { isError: true, content: [{ type: "text", text: `GlitchTip Service Error: ${msg}` }] }; + } }); async function run() { @@ -144,28 +151,51 @@ async function run() { const app = express(); const transports = new Map(); + // Manual CORS middleware + app.use((req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version'); + if (req.method === 'OPTIONS') { + res.status(204).end(); + return; + } + next(); + }); + app.use((req, _res, next) => { console.error(`${req.method} ${req.url}`); next(); }); app.get('/sse', async (req, res) => { - const sessionId = crypto.randomUUID(); + const transport = new SSEServerTransport('/message', res as any); + const sessionId = transport.sessionId; console.error(`New SSE connection: ${sessionId}`); - const transport = new SSEServerTransport(`/message/${sessionId}`, res); transports.set(sessionId, transport); + const heartbeatInterval = setInterval(() => { + res.write(": heartbeat\n\n"); + }, 15000); + req.on('close', () => { console.error(`SSE connection closed: ${sessionId}`); + clearInterval(heartbeatInterval); transports.delete(sessionId); }); await server.connect(transport); + + await new Promise((resolve) => { + req.on('close', resolve); + }); }); - app.post('/message/:sessionId', async (req: Request, res: Response) => { - const sessionId = req.params.sessionId; - const transport = transports.get(sessionId as string); + app.post('/message', express.json(), async (req, res) => { + const sessionId = req.query.sessionId as string; + const transport = transports.get(sessionId); + + console.error(`POST /message/${sessionId} - Active transports: ${transports.size}`); if (!transport) { console.error(`No transport found for session: ${sessionId}`); @@ -175,9 +205,9 @@ async function run() { await transport.handlePostMessage(req, res); }); - const PORT = process.env.GLITCHTIP_MCP_PORT || 3005; - app.listen(PORT, () => { - console.error(`GlitchTip MCP server running on http://localhost:${PORT}/sse`); + const PORT = Number(process.env.GLITCHTIP_MCP_PORT) || 3005; + app.listen(PORT, '0.0.0.0', () => { + console.error(`GlitchTip MCP server running on http://0.0.0.0:${PORT}/sse`); }); } } diff --git a/packages/klz-payload-mcp/package.json b/packages/klz-payload-mcp/package.json index 4c70a4d..d572924 100644 --- a/packages/klz-payload-mcp/package.json +++ b/packages/klz-payload-mcp/package.json @@ -10,13 +10,13 @@ "dev": "tsx watch src/index.ts" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.1", + "@modelcontextprotocol/sdk": "^1.5.0", "axios": "^1.7.2", "dotenv": "^17.3.1", - "express": "^5.2.1" + "express": "^4.19.2" }, "devDependencies": { - "@types/express": "^5.0.6", + "@types/express": "^4.17.21", "@types/node": "^20.14.10", "tsx": "^4.19.2", "typescript": "^5.5.3" diff --git a/packages/klz-payload-mcp/src/index.ts b/packages/klz-payload-mcp/src/index.ts index 5ee1451..112eca9 100644 --- a/packages/klz-payload-mcp/src/index.ts +++ b/packages/klz-payload-mcp/src/index.ts @@ -591,15 +591,27 @@ async function run() { const app = express(); const transports = new Map(); + // Manual CORS middleware + app.use((req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version'); + if (req.method === 'OPTIONS') { + res.status(204).end(); + return; + } + next(); + }); + app.use((req, _res, next) => { console.error(`${req.method} ${req.url}`); next(); }); - app.get('/sse', async (req: Request, res: Response) => { - const sessionId = crypto.randomUUID(); + app.get('/sse', async (req, res) => { + const transport = new SSEServerTransport('/message', res as any); + const sessionId = transport.sessionId; console.error(`New SSE connection: ${sessionId}`); - const transport = new SSEServerTransport(`/message/${sessionId}`, res); transports.set(sessionId, transport); req.on('close', () => { @@ -608,11 +620,17 @@ async function run() { }); await server.connect(transport); + + await new Promise((resolve) => { + req.on('close', resolve); + }); }); - app.post('/message/:sessionId', async (req: Request, res: Response) => { - const sessionId = req.params.sessionId; - const transport = transports.get(sessionId as string); + app.post('/message', express.json(), async (req, res) => { + const sessionId = req.query.sessionId as string; + const transport = transports.get(sessionId); + + console.error(`POST /message/${sessionId} - Active transports: ${transports.size}`); if (!transport) { console.error(`No transport found for session: ${sessionId}`); @@ -622,9 +640,9 @@ async function run() { 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`); + const PORT = Number(process.env.KLZ_PAYLOAD_MCP_PORT) || 3006; + app.listen(PORT, '0.0.0.0', () => { + console.error(`KLZ Payload MCP server running on http://0.0.0.0:${PORT}/sse`); }); } } diff --git a/packages/memory-mcp/package.json b/packages/memory-mcp/package.json index dce2619..b18b049 100644 --- a/packages/memory-mcp/package.json +++ b/packages/memory-mcp/package.json @@ -16,11 +16,11 @@ "@xenova/transformers": "^2.17.2", "onnxruntime-node": "^1.14.0", "dotenv": "^17.3.1", - "express": "^5.2.1", + "express": "^4.19.2", "zod": "^3.23.8" }, "devDependencies": { - "@types/express": "^5.0.6", + "@types/express": "^4.17.21", "@types/node": "^20.14.10", "tsx": "^4.19.1", "typescript": "^5.5.3", diff --git a/packages/memory-mcp/src/index.ts b/packages/memory-mcp/src/index.ts index e6be58f..8f648d5 100644 --- a/packages/memory-mcp/src/index.ts +++ b/packages/memory-mcp/src/index.ts @@ -80,28 +80,51 @@ async function main() { const app = express(); const transports = new Map(); + // Manual CORS middleware + app.use((req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version'); + if (req.method === 'OPTIONS') { + res.status(204).end(); + return; + } + next(); + }); + app.use((req, _res, next) => { console.error(`${req.method} ${req.url}`); next(); }); app.get('/sse', async (req, res) => { - const sessionId = crypto.randomUUID(); + const transport = new SSEServerTransport('/message', res as any); + const sessionId = transport.sessionId; console.error(`New SSE connection: ${sessionId}`); - const transport = new SSEServerTransport(`/message/${sessionId}`, res); transports.set(sessionId, transport); + const heartbeatInterval = setInterval(() => { + res.write(": heartbeat\n\n"); + }, 15000); + req.on('close', () => { console.error(`SSE connection closed: ${sessionId}`); + clearInterval(heartbeatInterval); transports.delete(sessionId); }); await server.connect(transport); + + await new Promise((resolve) => { + req.on('close', resolve); + }); }); - app.post('/message/:sessionId', async (req, res) => { - const { sessionId } = req.params; - const transport = transports.get(sessionId as string); + app.post('/message', express.json(), async (req, res) => { + const sessionId = req.query.sessionId as string; + const transport = transports.get(sessionId); + + console.error(`POST /message/${sessionId} - Active transports: ${transports.size}`); if (!transport) { console.error(`No transport found for session: ${sessionId}`); @@ -111,9 +134,9 @@ async function main() { await transport.handlePostMessage(req, res); }); - const PORT = process.env.MEMORY_MCP_PORT || 3002; - app.listen(PORT, async () => { - console.error(`Memory MCP server running on http://localhost:${PORT}/sse`); + const PORT = Number(process.env.MEMORY_MCP_PORT) || 3002; + app.listen(PORT, '0.0.0.0', async () => { + console.error(`Memory MCP server running on http://0.0.0.0:${PORT}/sse`); // Initialize dependencies in SSE mode on startup try { await qdrantService.initialize(); diff --git a/packages/next-config/index.js b/packages/next-config/index.js index 2578c72..56aaa9b 100644 --- a/packages/next-config/index.js +++ b/packages/next-config/index.js @@ -8,8 +8,10 @@ import path from "node:path"; export const baseNextConfig = { output: "standalone", turbopack: {}, - serverActions: { - allowedOrigins: ["*.klz-cables.com", "*.branch.klz-cables.com", "localhost:3000", "*.mintel.me"], + experimental: { + serverActions: { + allowedOrigins: ["*.klz-cables.com", "*.branch.klz-cables.com", "localhost:3000", "*.mintel.me"], + }, }, images: { dangerouslyAllowSVG: true, diff --git a/packages/payload-ai/package.json b/packages/payload-ai/package.json index 871f7c8..df232b3 100644 --- a/packages/payload-ai/package.json +++ b/packages/payload-ai/package.json @@ -1,6 +1,6 @@ { "name": "@mintel/payload-ai", - "version": "1.9.17", + "version": "1.9.18", "description": "Reusable Payload CMS AI Extensions", "type": "module", "scripts": { diff --git a/packages/payload-ai/src/tools/mcpAdapter.ts b/packages/payload-ai/src/tools/mcpAdapter.ts index 00f79f6..14a8773 100644 --- a/packages/payload-ai/src/tools/mcpAdapter.ts +++ b/packages/payload-ai/src/tools/mcpAdapter.ts @@ -1,9 +1,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { tool } from "ai"; -import { z } from "zod"; - +import { tool, jsonSchema } from "ai"; /** * Connects to an external MCP Server and maps its tools to Vercel AI SDK Tools. */ @@ -54,9 +52,7 @@ export async function createMcpTools(mcpConfig: { aiSdkTools[`${mcpConfig.name}_${extTool.name}`] = tool({ description: `[From ${mcpConfig.name}] ${extTool.description || extTool.name}`, - parameters: z - .any() - .describe("JSON matching the original MCP input_schema"), // Simplify for prototype + parameters: jsonSchema(toolSchema as any), // @ts-expect-error - AI strict mode overload bug with implicit zod inferences execute: async (args: any) => { const result = await client.callTool({ diff --git a/packages/serpbear-mcp/package.json b/packages/serpbear-mcp/package.json index 1582d5c..77f9ae3 100644 --- a/packages/serpbear-mcp/package.json +++ b/packages/serpbear-mcp/package.json @@ -13,10 +13,10 @@ "@modelcontextprotocol/sdk": "^1.5.0", "axios": "^1.7.2", "dotenv": "^17.3.1", - "express": "^5.2.1" + "express": "^4.19.2" }, "devDependencies": { - "@types/express": "^5.0.6", + "@types/express": "^4.17.21", "@types/node": "^20.14.10", "tsx": "^4.19.2", "typescript": "^5.5.3" diff --git a/packages/serpbear-mcp/src/index.ts b/packages/serpbear-mcp/src/index.ts index ab838a8..2a2ac6b 100644 --- a/packages/serpbear-mcp/src/index.ts +++ b/packages/serpbear-mcp/src/index.ts @@ -19,10 +19,28 @@ if (!SERPBEAR_API_KEY) { const serpbearClient = axios.create({ baseURL: `${SERPBEAR_BASE_URL}/api`, - headers: { apiKey: SERPBEAR_API_KEY }, + headers: { 'x-api-key': SERPBEAR_API_KEY || '' }, httpsAgent: new https.Agent({ rejectUnauthorized: false, }), + timeout: 10000 +}); + +// Logging middleware for SerpBear client +serpbearClient.interceptors.request.use((config) => { + console.error(`SerpBear Request: ${config.method?.toUpperCase()} ${config.url}`); + return config; +}, (error) => { + console.error(`SerpBear Request Error: ${error.message}`); + return Promise.reject(error); +}); + +serpbearClient.interceptors.response.use((response) => { + return response; +}, (error) => { + const msg = error.response?.data?.error || error.response?.data?.message || error.message; + console.error(`SerpBear API Error (${error.config?.url}): ${msg}`); + return Promise.reject(error); }); @@ -126,21 +144,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ })); server.setRequestHandler(CallToolRequestSchema, async (request) => { - if (request.params.name === "serpbear_list_domains") { - try { + try { + if (request.params.name === "serpbear_list_domains") { const res = await serpbearClient.get('/domains'); const domains = (res.data.domains || []).map((d: any) => ({ id: d.id, domain: d.domain, keywords: d.keywordCount })); return { content: [{ type: "text", text: JSON.stringify(domains, null, 2) }] }; - } catch (e: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; } - } - if (request.params.name === "serpbear_get_keywords") { - const { domain_id } = request.params.arguments as any; - try { + if (request.params.name === "serpbear_get_keywords") { + const { domain_id } = request.params.arguments as any; const res = await serpbearClient.get('/keywords', { params: { domain: domain_id } }); const keywords = (res.data.keywords || []).map((k: any) => ({ id: k.id, @@ -152,55 +166,40 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { change: k.position_change ?? null, })); return { content: [{ type: "text", text: JSON.stringify(keywords, null, 2) }] }; - } catch (e: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; } - } - if (request.params.name === "serpbear_add_keywords") { - const { domain_id, keywords, country = 'de', device = 'desktop' } = request.params.arguments as any; - try { + if (request.params.name === "serpbear_add_keywords") { + const { domain_id, keywords, country = 'de', device = 'desktop' } = request.params.arguments as any; const res = await serpbearClient.post('/keywords', { domain: domain_id, keywords: keywords.map((kw: string) => ({ keyword: kw, country, device })), }); return { content: [{ type: "text", text: `Added ${keywords.length} keywords. Result: ${JSON.stringify(res.data)}` }] }; - } catch (e: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; } - } - if (request.params.name === "serpbear_delete_keywords") { - const { keyword_ids } = request.params.arguments as any; - try { + if (request.params.name === "serpbear_delete_keywords") { + const { keyword_ids } = request.params.arguments as any; await serpbearClient.delete('/keywords', { data: { ids: keyword_ids } }); return { content: [{ type: "text", text: `Deleted ${keyword_ids.length} keywords.` }] }; - } catch (e: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; } - } - if (request.params.name === "serpbear_refresh_keywords") { - const { keyword_ids } = request.params.arguments as any; - try { + if (request.params.name === "serpbear_refresh_keywords") { + const { keyword_ids } = request.params.arguments as any; await serpbearClient.post('/keywords/refresh', { ids: keyword_ids }); return { content: [{ type: "text", text: `Triggered refresh for ${keyword_ids.length} keywords.` }] }; - } catch (e: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; } - } - if (request.params.name === "serpbear_get_keyword_history") { - const { keyword_id } = request.params.arguments as any; - try { + if (request.params.name === "serpbear_get_keyword_history") { + const { keyword_id } = request.params.arguments as any; const res = await serpbearClient.get(`/keywords/${keyword_id}/history`); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; - } catch (e: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; } - } - throw new Error(`Unknown tool: ${request.params.name}`); + throw new Error(`Unknown tool: ${request.params.name}`); + } catch (e: any) { + const msg = e.response?.data?.error || e.response?.data?.message || e.message; + return { isError: true, content: [{ type: "text", text: `SerpBear Service Error: ${msg}` }] }; + } }); // --- Express / SSE Server --- @@ -216,28 +215,51 @@ async function run() { const app = express(); const transports = new Map(); + // Manual CORS middleware + app.use((req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version'); + if (req.method === 'OPTIONS') { + res.status(204).end(); + return; + } + next(); + }); + app.use((req, _res, next) => { console.error(`${req.method} ${req.url}`); next(); }); app.get('/sse', async (req, res) => { - const sessionId = crypto.randomUUID(); + const transport = new SSEServerTransport('/message', res as any); + const sessionId = transport.sessionId; console.error(`New SSE connection: ${sessionId}`); - const transport = new SSEServerTransport(`/message/${sessionId}`, res); transports.set(sessionId, transport); + const heartbeatInterval = setInterval(() => { + res.write(": heartbeat\n\n"); + }, 15000); + req.on('close', () => { console.error(`SSE connection closed: ${sessionId}`); + clearInterval(heartbeatInterval); transports.delete(sessionId); }); await server.connect(transport); + + await new Promise((resolve) => { + req.on('close', resolve); + }); }); - app.post('/message/:sessionId', async (req, res) => { - const { sessionId } = req.params; - const transport = transports.get(sessionId as string); + app.post('/message', express.json(), async (req, res) => { + const sessionId = req.query.sessionId as string; + const transport = transports.get(sessionId); + + console.error(`POST /message/${sessionId} - Active transports: ${transports.size}`); if (!transport) { console.error(`No transport found for session: ${sessionId}`); @@ -247,9 +269,9 @@ async function run() { await transport.handlePostMessage(req, res); }); - const PORT = process.env.SERPBEAR_MCP_PORT || 3004; - app.listen(PORT, () => { - console.error(`SerpBear MCP server running on http://localhost:${PORT}/sse`); + const PORT = Number(process.env.SERPBEAR_MCP_PORT) || 3004; + app.listen(PORT, '0.0.0.0', () => { + console.error(`SerpBear MCP server running on http://0.0.0.0:${PORT}/sse`); }); } } diff --git a/packages/umami-mcp/package.json b/packages/umami-mcp/package.json index c4152a2..f893316 100644 --- a/packages/umami-mcp/package.json +++ b/packages/umami-mcp/package.json @@ -13,10 +13,10 @@ "@modelcontextprotocol/sdk": "^1.5.0", "axios": "^1.7.2", "dotenv": "^17.3.1", - "express": "^5.2.1" + "express": "^4.19.2" }, "devDependencies": { - "@types/express": "^5.0.6", + "@types/express": "^4.17.21", "@types/node": "^20.14.10", "tsx": "^4.19.2", "typescript": "^5.5.3" diff --git a/packages/umami-mcp/src/index.ts b/packages/umami-mcp/src/index.ts index f2c8a74..acdc0e0 100644 --- a/packages/umami-mcp/src/index.ts +++ b/packages/umami-mcp/src/index.ts @@ -27,17 +27,28 @@ if (!UMAMI_USERNAME && !UMAMI_API_KEY) { let cachedToken: string | null = null; async function getAuthHeaders(): Promise> { - if (UMAMI_API_KEY) { - return { 'x-umami-api-key': UMAMI_API_KEY }; + try { + if (UMAMI_API_KEY) { + return { 'x-umami-api-key': UMAMI_API_KEY }; + } + if (!cachedToken) { + console.error(`Attempting Umami login for ${UMAMI_USERNAME} at ${UMAMI_BASE_URL}`); + const res = await axios.post(`${UMAMI_BASE_URL}/api/auth/login`, { + username: UMAMI_USERNAME, + password: UMAMI_PASSWORD, + }, { + httpsAgent, + timeout: 5000 // Add timeout to prevent hangs + }); + cachedToken = res.data.token; + console.error("Umami login successful"); + } + return { Authorization: `Bearer ${cachedToken}` }; + } catch (e: any) { + const msg = e.response?.data?.message || e.message; + console.error(`Umami Auth Error: ${msg}`); + throw new Error(`Authentication failed: ${msg}`); } - if (!cachedToken) { - const res = await axios.post(`${UMAMI_BASE_URL}/api/auth/login`, { - username: UMAMI_USERNAME, - password: UMAMI_PASSWORD, - }, { httpsAgent }); - cachedToken = res.data.token; - } - return { Authorization: `Bearer ${cachedToken}` }; } @@ -153,92 +164,72 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ })); server.setRequestHandler(CallToolRequestSchema, async (request) => { - const headers = await getAuthHeaders(); - const api = axios.create({ baseURL: `${UMAMI_BASE_URL}/api`, headers, httpsAgent }); - - const now = Date.now(); + try { + const headers = await getAuthHeaders(); + const api = axios.create({ baseURL: `${UMAMI_BASE_URL}/api`, headers, httpsAgent, timeout: 10000 }); + const now = Date.now(); if (request.params.name === "umami_list_websites") { - try { - const res = await api.get('/websites'); - const sites = (res.data.data || res.data || []).map((s: any) => ({ - id: s.id, name: s.name, domain: s.domain - })); - return { content: [{ type: "text", text: JSON.stringify(sites, null, 2) }] }; - } catch (e: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; - } + const res = await api.get('/websites'); + const sites = (res.data.data || res.data || []).map((s: any) => ({ + id: s.id, name: s.name, domain: s.domain + })); + return { content: [{ type: "text", text: JSON.stringify(sites, null, 2) }] }; } if (request.params.name === "umami_get_website_stats") { const { website_id, start_at, end_at = now } = request.params.arguments as any; - try { - const res = await api.get(`/websites/${website_id}/stats`, { params: { startAt: start_at, endAt: end_at } }); - return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; - } catch (e: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; - } + const res = await api.get(`/websites/${website_id}/stats`, { params: { startAt: start_at, endAt: end_at } }); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } if (request.params.name === "umami_get_pageviews") { const { website_id, start_at, end_at = now, unit = 'day', timezone = 'Europe/Berlin' } = request.params.arguments as any; - try { - const res = await api.get(`/websites/${website_id}/pageviews`, { - params: { startAt: start_at, endAt: end_at, unit, timezone } - }); - return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; - } catch (e: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; - } + const res = await api.get(`/websites/${website_id}/pageviews`, { + params: { startAt: start_at, endAt: end_at, unit, timezone } + }); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } if (request.params.name === "umami_get_top_pages") { const { website_id, start_at, end_at = now, limit = 20 } = request.params.arguments as any; - try { - const res = await api.get(`/websites/${website_id}/metrics`, { - params: { startAt: start_at, endAt: end_at, type: 'url', limit } - }); - return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; - } catch (e: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; - } + const res = await api.get(`/websites/${website_id}/metrics`, { + params: { startAt: start_at, endAt: end_at, type: 'url', limit } + }); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } if (request.params.name === "umami_get_top_referrers") { const { website_id, start_at, end_at = now, limit = 10 } = request.params.arguments as any; - try { - const res = await api.get(`/websites/${website_id}/metrics`, { - params: { startAt: start_at, endAt: end_at, type: 'referrer', limit } - }); - return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; - } catch (e: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; - } + const res = await api.get(`/websites/${website_id}/metrics`, { + params: { startAt: start_at, endAt: end_at, type: 'referrer', limit } + }); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } if (request.params.name === "umami_get_country_stats") { const { website_id, start_at, end_at = now } = request.params.arguments as any; - try { - const res = await api.get(`/websites/${website_id}/metrics`, { - params: { startAt: start_at, endAt: end_at, type: 'country' } - }); - return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; - } catch (e: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; - } + const res = await api.get(`/websites/${website_id}/metrics`, { + params: { startAt: start_at, endAt: end_at, type: 'country' } + }); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } if (request.params.name === "umami_get_active_visitors") { const { website_id } = request.params.arguments as any; - try { - const res = await api.get(`/websites/${website_id}/active`); - return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; - } catch (e: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] }; - } + const res = await api.get(`/websites/${website_id}/active`); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } throw new Error(`Unknown tool: ${request.params.name}`); + + + } catch (e: any) { + return { + isError: true, + content: [{ type: "text", text: `MCP Service Error: ${e.message}` }] + }; + } }); // --- Express / SSE Server --- @@ -254,28 +245,51 @@ async function run() { const app = express(); const transports = new Map(); + // Manual CORS middleware + app.use((req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version'); + if (req.method === 'OPTIONS') { + res.status(204).end(); + return; + } + next(); + }); + app.use((req, _res, next) => { console.error(`${req.method} ${req.url}`); next(); }); app.get('/sse', async (req, res) => { - const sessionId = crypto.randomUUID(); + const transport = new SSEServerTransport('/message', res as any); + const sessionId = transport.sessionId; console.error(`New SSE connection: ${sessionId}`); - const transport = new SSEServerTransport(`/message/${sessionId}`, res); transports.set(sessionId, transport); + const heartbeatInterval = setInterval(() => { + res.write(": heartbeat\n\n"); + }, 15000); + req.on('close', () => { console.error(`SSE connection closed: ${sessionId}`); + clearInterval(heartbeatInterval); transports.delete(sessionId); }); await server.connect(transport); + + await new Promise((resolve) => { + req.on('close', resolve); + }); }); - app.post('/message/:sessionId', async (req, res) => { - const { sessionId } = req.params; - const transport = transports.get(sessionId as string); + app.post('/message', express.json(), async (req, res) => { + const sessionId = req.query.sessionId as string; + const transport = transports.get(sessionId); + + console.error(`POST /message/${sessionId} - Active transports: ${transports.size}`); if (!transport) { console.error(`No transport found for session: ${sessionId}`); @@ -285,9 +299,9 @@ async function run() { await transport.handlePostMessage(req, res); }); - const PORT = process.env.UMAMI_MCP_PORT || 3003; - app.listen(PORT, () => { - console.error(`Umami MCP server running on http://localhost:${PORT}/sse`); + const PORT = Number(process.env.UMAMI_MCP_PORT) || 3003; + app.listen(PORT, '0.0.0.0', () => { + console.error(`Umami MCP server running on http://0.0.0.0:${PORT}/sse`); }); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50c09fc..0c77514 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -477,52 +477,6 @@ importers: specifier: ^5.0.0 version: 5.9.3 - packages/kabelfachmann-mcp: - dependencies: - '@modelcontextprotocol/sdk': - specifier: ^1.5.0 - version: 1.27.1(zod@3.25.76) - '@qdrant/js-client-rest': - specifier: ^1.12.0 - version: 1.17.0(typescript@5.9.3) - '@xenova/transformers': - specifier: ^2.17.2 - version: 2.17.2 - dotenv: - specifier: ^17.3.1 - version: 17.3.1 - express: - specifier: ^5.2.1 - version: 5.2.1 - node-fetch: - specifier: ^3.3.2 - version: 3.3.2 - onnxruntime-node: - specifier: ^1.14.0 - version: 1.14.0 - pdf-parse: - specifier: ^1.1.1 - version: 1.1.4 - 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 - '@types/pdf-parse': - specifier: ^1.1.4 - version: 1.1.5 - tsx: - specifier: ^4.19.1 - version: 4.21.0 - typescript: - specifier: ^5.5.3 - version: 5.9.3 - packages/klz-payload-mcp: dependencies: '@modelcontextprotocol/sdk': @@ -3503,9 +3457,6 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} - '@types/pdf-parse@1.1.5': - resolution: {integrity: sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==} - '@types/pg-pool@2.0.7': resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} @@ -4639,10 +4590,6 @@ packages: resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==} engines: {node: '>=12'} - data-uri-to-buffer@4.0.1: - resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} - engines: {node: '>= 12'} - data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -5231,10 +5178,6 @@ packages: picomatch: optional: true - fetch-blob@3.2.0: - resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} - engines: {node: ^12.20 || >= 14.13} - fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -5345,10 +5288,6 @@ packages: resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} engines: {node: '>= 12.20'} - formdata-polyfill@4.0.10: - resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} - engines: {node: '>=12.20.0'} - forwarded-parse@2.1.2: resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} @@ -6571,9 +6510,6 @@ packages: engines: {node: '>=10.5.0'} deprecated: Use your platform's native DOMException instead - node-ensure@0.0.0: - resolution: {integrity: sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==} - node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -6583,10 +6519,6 @@ packages: encoding: optional: true - node-fetch@3.3.2: - resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -6848,10 +6780,6 @@ packages: peerDependencies: graphql: ^16.8.1 - pdf-parse@1.1.4: - resolution: {integrity: sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==} - engines: {node: '>=6.8.1'} - peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} @@ -8392,10 +8320,6 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - web-streams-polyfill@3.3.3: - resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} - engines: {node: '>= 8'} - web-streams-polyfill@4.0.0-beta.3: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} @@ -11387,10 +11311,6 @@ snapshots: '@types/parse-json@4.0.2': {} - '@types/pdf-parse@1.1.5': - dependencies: - '@types/node': 20.19.33 - '@types/pg-pool@2.0.7': dependencies: '@types/pg': 8.15.6 @@ -12609,8 +12529,6 @@ snapshots: dargs@8.1.0: {} - data-uri-to-buffer@4.0.1: {} - data-uri-to-buffer@6.0.2: {} data-urls@5.0.0: @@ -13393,11 +13311,6 @@ snapshots: optionalDependencies: picomatch: 4.0.3 - fetch-blob@3.2.0: - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 3.3.3 - fflate@0.8.2: {} figlet@1.10.0: @@ -13528,10 +13441,6 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 4.0.0-beta.3 - formdata-polyfill@4.0.10: - dependencies: - fetch-blob: 3.2.0 - forwarded-parse@2.1.2: {} forwarded@0.2.0: {} @@ -14779,18 +14688,10 @@ snapshots: node-domexception@1.0.0: {} - node-ensure@0.0.0: {} - node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 - node-fetch@3.3.2: - dependencies: - data-uri-to-buffer: 4.0.1 - fetch-blob: 3.2.0 - formdata-polyfill: 4.0.10 - node-releases@2.0.27: {} normalize-path@3.0.0: {} @@ -15109,10 +15010,6 @@ snapshots: - typescript - utf-8-validate - pdf-parse@1.1.4: - dependencies: - node-ensure: 0.0.0 - peberminta@0.9.0: {} peek-readable@5.4.2: {} @@ -17096,8 +16993,6 @@ snapshots: dependencies: defaults: 1.0.4 - web-streams-polyfill@3.3.3: {} - web-streams-polyfill@4.0.0-beta.3: {} webidl-conversions@3.0.1: {}