From 5c10eb0009fd4c956b6c14d7cf9ea98f002eed06 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Thu, 5 Mar 2026 10:52:05 +0100 Subject: [PATCH] feat(mcps): add wiki/packages/releases/projects to gitea + new umami & serpbear MCPs --- ecosystem.mcps.config.cjs | 22 +- packages/gitea-mcp/src/index.ts | 314 +++++++++++++++++++++++++++- packages/serpbear-mcp/package.json | 25 +++ packages/serpbear-mcp/src/index.ts | 225 ++++++++++++++++++++ packages/serpbear-mcp/src/start.ts | 13 ++ packages/serpbear-mcp/tsconfig.json | 16 ++ packages/umami-mcp/package.json | 25 +++ packages/umami-mcp/src/index.ts | 262 +++++++++++++++++++++++ packages/umami-mcp/src/start.ts | 13 ++ packages/umami-mcp/tsconfig.json | 16 ++ pnpm-lock.yaml | 62 ++++++ 11 files changed, 985 insertions(+), 8 deletions(-) create mode 100644 packages/serpbear-mcp/package.json create mode 100644 packages/serpbear-mcp/src/index.ts create mode 100644 packages/serpbear-mcp/src/start.ts create mode 100644 packages/serpbear-mcp/tsconfig.json create mode 100644 packages/umami-mcp/package.json create mode 100644 packages/umami-mcp/src/index.ts create mode 100644 packages/umami-mcp/src/start.ts create mode 100644 packages/umami-mcp/tsconfig.json diff --git a/ecosystem.mcps.config.cjs b/ecosystem.mcps.config.cjs index 6c03bc7..1ca1cae 100644 --- a/ecosystem.mcps.config.cjs +++ b/ecosystem.mcps.config.cjs @@ -6,9 +6,6 @@ module.exports = { args: 'dist/start.js', cwd: './packages/gitea-mcp', watch: false, - env: { - NODE_ENV: 'production' - } }, { name: 'memory-mcp', @@ -16,9 +13,20 @@ module.exports = { args: 'dist/start.js', cwd: './packages/memory-mcp', watch: false, - env: { - NODE_ENV: 'production' - } - } + }, + { + name: 'umami-mcp', + script: 'node', + args: 'dist/start.js', + cwd: './packages/umami-mcp', + watch: false, + }, + { + name: 'serpbear-mcp', + script: 'node', + args: 'dist/start.js', + cwd: './packages/serpbear-mcp', + watch: false, + }, ] }; diff --git a/packages/gitea-mcp/src/index.ts b/packages/gitea-mcp/src/index.ts index 0df240c..d125f60 100644 --- a/packages/gitea-mcp/src/index.ts +++ b/packages/gitea-mcp/src/index.ts @@ -180,6 +180,169 @@ const SEARCH_REPOS_TOOL: Tool = { }, }; +// --- Wiki --- +const LIST_WIKI_PAGES_TOOL: Tool = { + name: "gitea_list_wiki_pages", + description: "List all wiki pages of a repository", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + }, + required: ["owner", "repo"], + }, +}; + +const GET_WIKI_PAGE_TOOL: Tool = { + name: "gitea_get_wiki_page", + description: "Get the content of a specific wiki page", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + page_name: { type: "string", description: "Name/slug of the wiki page (e.g., 'Home')" }, + }, + required: ["owner", "repo", "page_name"], + }, +}; + +const CREATE_WIKI_PAGE_TOOL: Tool = { + name: "gitea_create_wiki_page", + description: "Create a new wiki page in a repository", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + title: { type: "string", description: "Page title" }, + content: { type: "string", description: "Page content in Markdown (base64 encoded internally)" }, + message: { type: "string", description: "Optional commit message" }, + }, + required: ["owner", "repo", "title", "content"], + }, +}; + +const EDIT_WIKI_PAGE_TOOL: Tool = { + name: "gitea_edit_wiki_page", + description: "Edit an existing wiki page", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + page_name: { type: "string", description: "Current name/slug of the wiki page" }, + title: { type: "string", description: "Optional: new title" }, + content: { type: "string", description: "New content in Markdown" }, + message: { type: "string", description: "Optional commit message" }, + }, + required: ["owner", "repo", "page_name", "content"], + }, +}; + +// --- Packages --- +const LIST_PACKAGES_TOOL: Tool = { + name: "gitea_list_packages", + description: "List packages published to the Gitea package registry for a user or org", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "User or organization name" }, + type: { type: "string", description: "Optional: Package type filter (e.g., 'npm', 'docker', 'generic')" }, + limit: { type: "number", description: "Number of packages to return (default: 10)" }, + }, + required: ["owner"], + }, +}; + +const LIST_PACKAGE_VERSIONS_TOOL: Tool = { + name: "gitea_list_package_versions", + description: "List all published versions of a specific package", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "User or organization name" }, + type: { type: "string", description: "Package type (e.g., 'npm', 'docker')" }, + name: { type: "string", description: "Package name" }, + }, + required: ["owner", "type", "name"], + }, +}; + +// --- Releases --- +const LIST_RELEASES_TOOL: Tool = { + name: "gitea_list_releases", + description: "List releases for a repository", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + limit: { type: "number", description: "Number of releases to fetch (default: 10)" }, + }, + required: ["owner", "repo"], + }, +}; + +const GET_LATEST_RELEASE_TOOL: Tool = { + name: "gitea_get_latest_release", + description: "Get the latest release for a repository", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + }, + required: ["owner", "repo"], + }, +}; + +const CREATE_RELEASE_TOOL: Tool = { + name: "gitea_create_release", + description: "Create a new release for a repository", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + tag_name: { type: "string", description: "Git tag to build the release from (e.g., 'v1.2.3')" }, + name: { type: "string", description: "Release title" }, + body: { type: "string", description: "Optional: Release notes/description in Markdown" }, + draft: { type: "boolean", description: "Optional: Create as draft (default: false)" }, + prerelease: { type: "boolean", description: "Optional: Mark as prerelease (default: false)" }, + }, + required: ["owner", "repo", "tag_name", "name"], + }, +}; + +// --- Projects --- +const LIST_PROJECTS_TOOL: Tool = { + name: "gitea_list_projects", + description: "List projects (kanban boards) for a user, organization, or repository", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "User or organization name" }, + repo: { type: "string", description: "Optional: Repository name (for repo-level projects)" }, + type: { type: "string", description: "Optional: 'individual' or 'repository' or 'organization'" }, + }, + required: ["owner"], + }, +}; + +const GET_PROJECT_COLUMNS_TOOL: Tool = { + name: "gitea_get_project_columns", + description: "Get the columns (board columns) of a specific project", + inputSchema: { + type: "object", + properties: { + project_id: { type: "number", description: "Numeric project ID from gitea_list_projects" }, + }, + required: ["project_id"], + }, +}; + // Subscription State const subscriptions = new Set(); const runStatusCache = new Map(); // uri -> status @@ -210,7 +373,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { UPDATE_ISSUE_TOOL, CREATE_ISSUE_COMMENT_TOOL, CREATE_PULL_REQUEST_TOOL, - SEARCH_REPOS_TOOL + SEARCH_REPOS_TOOL, + // Wiki + LIST_WIKI_PAGES_TOOL, + GET_WIKI_PAGE_TOOL, + CREATE_WIKI_PAGE_TOOL, + EDIT_WIKI_PAGE_TOOL, + // Packages + LIST_PACKAGES_TOOL, + LIST_PACKAGE_VERSIONS_TOOL, + // Releases + LIST_RELEASES_TOOL, + GET_LATEST_RELEASE_TOOL, + CREATE_RELEASE_TOOL, + // Projects + LIST_PROJECTS_TOOL, + GET_PROJECT_COLUMNS_TOOL, ], }; }); @@ -410,6 +588,140 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } } + // --- Wiki Handlers --- + if (request.params.name === "gitea_list_wiki_pages") { + const { owner, repo } = request.params.arguments as any; + try { + const response = await giteaClient.get(`/repos/${owner}/${repo}/wiki/pages`); + const pages = (response.data || []).map((p: any) => ({ title: p.title, last_commit: p.last_commit?.message })); + return { content: [{ type: "text", text: JSON.stringify(pages, null, 2) }] }; + } catch (error: any) { + return { isError: true, content: [{ type: "text", text: `Error listing wiki pages: ${error.message}` }] }; + } + } + + if (request.params.name === "gitea_get_wiki_page") { + const { owner, repo, page_name } = request.params.arguments as any; + try { + const response = await giteaClient.get(`/repos/${owner}/${repo}/wiki/page/${encodeURIComponent(page_name)}`); + const content = Buffer.from(response.data.content_base64 || '', 'base64').toString('utf-8'); + return { content: [{ type: "text", text: `# ${response.data.title}\n\n${content}` }] }; + } catch (error: any) { + return { isError: true, content: [{ type: "text", text: `Error fetching wiki page: ${error.message}` }] }; + } + } + + if (request.params.name === "gitea_create_wiki_page") { + const { owner, repo, title, content, message } = request.params.arguments as any; + try { + const response = await giteaClient.post(`/repos/${owner}/${repo}/wiki/pages`, { + title, + content_base64: Buffer.from(content).toString('base64'), + message: message || `Create wiki page: ${title}`, + }); + return { content: [{ type: "text", text: `Wiki page '${response.data.title}' created.` }] }; + } catch (error: any) { + return { isError: true, content: [{ type: "text", text: `Error creating wiki page: ${error.message}` }] }; + } + } + + if (request.params.name === "gitea_edit_wiki_page") { + const { owner, repo, page_name, title, content, message } = request.params.arguments as any; + try { + const updateData: Record = { + content_base64: Buffer.from(content).toString('base64'), + message: message || `Update wiki page: ${page_name}`, + }; + if (title) updateData.title = title; + const response = await giteaClient.patch(`/repos/${owner}/${repo}/wiki/pages/${encodeURIComponent(page_name)}`, updateData); + return { content: [{ type: "text", text: `Wiki page '${response.data.title}' updated.` }] }; + } catch (error: any) { + return { isError: true, content: [{ type: "text", text: `Error updating wiki page: ${error.message}` }] }; + } + } + + // --- Package Handlers --- + if (request.params.name === "gitea_list_packages") { + const { owner, type, limit = 10 } = request.params.arguments as any; + try { + const params: Record = { limit }; + if (type) params.type = type; + const response = await giteaClient.get(`/packages/${owner}`, { params }); + const packages = (response.data || []).map((p: any) => ({ + name: p.name, type: p.type, version: p.version, created: p.created_at + })); + return { content: [{ type: "text", text: JSON.stringify(packages, null, 2) }] }; + } catch (error: any) { + return { isError: true, content: [{ type: "text", text: `Error listing packages: ${error.message}` }] }; + } + } + + if (request.params.name === "gitea_list_package_versions") { + const { owner, type, name } = request.params.arguments as any; + try { + const response = await giteaClient.get(`/packages/${owner}/${type}/${encodeURIComponent(name)}`); + return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; + } catch (error: any) { + return { isError: true, content: [{ type: "text", text: `Error listing package versions: ${error.message}` }] }; + } + } + + // --- Release Handlers --- + if (request.params.name === "gitea_list_releases") { + const { owner, repo, limit = 10 } = request.params.arguments as any; + try { + const response = await giteaClient.get(`/repos/${owner}/${repo}/releases`, { params: { limit } }); + return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; + } catch (error: any) { + return { isError: true, content: [{ type: "text", text: `Error listing releases: ${error.message}` }] }; + } + } + + if (request.params.name === "gitea_get_latest_release") { + const { owner, repo } = request.params.arguments as any; + try { + const response = await giteaClient.get(`/repos/${owner}/${repo}/releases/latest`); + return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; + } catch (error: any) { + return { isError: true, content: [{ type: "text", text: `Error fetching latest release: ${error.message}` }] }; + } + } + + if (request.params.name === "gitea_create_release") { + const { owner, repo, tag_name, name, body, draft = false, prerelease = false } = request.params.arguments as any; + try { + const response = await giteaClient.post(`/repos/${owner}/${repo}/releases`, { + tag_name, name, body, draft, prerelease + }); + return { content: [{ type: "text", text: `Release '${response.data.name}' created: ${response.data.html_url}` }] }; + } catch (error: any) { + return { isError: true, content: [{ type: "text", text: `Error creating release: ${error.message}` }] }; + } + } + + // --- Project Handlers --- + if (request.params.name === "gitea_list_projects") { + const { owner, repo } = request.params.arguments as any; + try { + // Gitea API: repo-level projects or user projects + const url = repo ? `/repos/${owner}/${repo}/projects` : `/users/${owner}/projects`; + const response = await giteaClient.get(url); + return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; + } catch (error: any) { + return { isError: true, content: [{ type: "text", text: `Error listing projects: ${error.message}` }] }; + } + } + + if (request.params.name === "gitea_get_project_columns") { + const { project_id } = request.params.arguments as any; + try { + const response = await giteaClient.get(`/projects/${project_id}/columns`); + return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; + } catch (error: any) { + return { isError: true, content: [{ type: "text", text: `Error fetching project columns: ${error.message}` }] }; + } + } + throw new Error(`Unknown tool: ${request.params.name}`); }); diff --git a/packages/serpbear-mcp/package.json b/packages/serpbear-mcp/package.json new file mode 100644 index 0000000..4b29d85 --- /dev/null +++ b/packages/serpbear-mcp/package.json @@ -0,0 +1,25 @@ +{ + "name": "@mintel/serpbear-mcp", + "version": "1.9.10", + "description": "SerpBear SEO Tracking MCP server for Mintel infrastructure", + "main": "dist/index.js", + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/start.js", + "dev": "tsx watch src/index.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.5.0", + "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/serpbear-mcp/src/index.ts b/packages/serpbear-mcp/src/index.ts new file mode 100644 index 0000000..cbfcab1 --- /dev/null +++ b/packages/serpbear-mcp/src/index.ts @@ -0,0 +1,225 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import express from 'express'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import axios from "axios"; + +const SERPBEAR_BASE_URL = process.env.SERPBEAR_BASE_URL || "https://serpbear.infra.mintel.me"; +const SERPBEAR_API_KEY = process.env.SERPBEAR_API_KEY; + +if (!SERPBEAR_API_KEY) { + console.error("Warning: SERPBEAR_API_KEY is not set. API calls will fail."); +} + +const serpbearClient = axios.create({ + baseURL: `${SERPBEAR_BASE_URL}/api`, + headers: { apiKey: SERPBEAR_API_KEY }, +}); + +// --- Tool Definitions --- +const LIST_DOMAINS_TOOL: Tool = { + name: "serpbear_list_domains", + description: "List all domains/projects tracked in SerpBear", + inputSchema: { type: "object", properties: {} }, +}; + +const GET_KEYWORDS_TOOL: Tool = { + name: "serpbear_get_keywords", + description: "Get all tracked keywords for a domain, with their current ranking positions", + inputSchema: { + type: "object", + properties: { + domain_id: { type: "string", description: "Domain ID from serpbear_list_domains" }, + }, + required: ["domain_id"], + }, +}; + +const ADD_KEYWORDS_TOOL: Tool = { + name: "serpbear_add_keywords", + description: "Add new keywords to track for a domain", + inputSchema: { + type: "object", + properties: { + domain_id: { type: "string", description: "Domain ID" }, + keywords: { + type: "array", + items: { type: "string" }, + description: "List of keywords to add (e.g., ['Webentwickler Frankfurt', 'Next.js Agentur'])" + }, + country: { type: "string", description: "Country code for SERP tracking (e.g., 'de', 'us'). Default: 'de'" }, + device: { type: "string", description: "Device type: 'desktop' or 'mobile'. Default: 'desktop'" }, + }, + required: ["domain_id", "keywords"], + }, +}; + +const DELETE_KEYWORDS_TOOL: Tool = { + name: "serpbear_delete_keywords", + description: "Remove keywords from tracking", + inputSchema: { + type: "object", + properties: { + keyword_ids: { + type: "array", + items: { type: "number" }, + description: "Array of keyword IDs to delete" + }, + }, + required: ["keyword_ids"], + }, +}; + +const REFRESH_KEYWORDS_TOOL: Tool = { + name: "serpbear_refresh_keywords", + description: "Trigger an immediate SERP position refresh for specific keywords", + inputSchema: { + type: "object", + properties: { + keyword_ids: { + type: "array", + items: { type: "number" }, + description: "List of keyword IDs to refresh" + }, + }, + required: ["keyword_ids"], + }, +}; + +const GET_KEYWORD_HISTORY_TOOL: Tool = { + name: "serpbear_get_keyword_history", + description: "Get the ranking history for a specific keyword over time", + inputSchema: { + type: "object", + properties: { + keyword_id: { type: "number", description: "Keyword ID from serpbear_get_keywords" }, + }, + required: ["keyword_id"], + }, +}; + +// --- Server Setup --- +const server = new Server( + { name: "serpbear-mcp", version: "1.0.0" }, + { capabilities: { tools: {} } } +); + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + LIST_DOMAINS_TOOL, + GET_KEYWORDS_TOOL, + ADD_KEYWORDS_TOOL, + DELETE_KEYWORDS_TOOL, + REFRESH_KEYWORDS_TOOL, + GET_KEYWORD_HISTORY_TOOL, + ], +})); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === "serpbear_list_domains") { + try { + 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 { + const res = await serpbearClient.get('/keywords', { params: { domain: domain_id } }); + const keywords = (res.data.keywords || []).map((k: any) => ({ + id: k.id, + keyword: k.keyword, + position: k.position, + lastUpdated: k.lastUpdated, + country: k.country, + device: k.device, + 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 { + 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 { + 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 { + 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 { + 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}`); +}); + +// --- Express / SSE Server --- +async function run() { + const app = express(); + let transport: SSEServerTransport | null = null; + + app.get('/sse', async (req, res) => { + console.error('New SSE connection established'); + transport = new SSEServerTransport('/message', res); + await server.connect(transport); + }); + + app.post('/message', async (req, res) => { + if (!transport) { res.status(400).send('No active SSE connection'); return; } + 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`); + }); +} + +run().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/packages/serpbear-mcp/src/start.ts b/packages/serpbear-mcp/src/start.ts new file mode 100644 index 0000000..53fd0d5 --- /dev/null +++ b/packages/serpbear-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 SerpBear MCP Server:', err); + process.exit(1); +}); diff --git a/packages/serpbear-mcp/tsconfig.json b/packages/serpbear-mcp/tsconfig.json new file mode 100644 index 0000000..0e88912 --- /dev/null +++ b/packages/serpbear-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/packages/umami-mcp/package.json b/packages/umami-mcp/package.json new file mode 100644 index 0000000..921561f --- /dev/null +++ b/packages/umami-mcp/package.json @@ -0,0 +1,25 @@ +{ + "name": "@mintel/umami-mcp", + "version": "1.9.10", + "description": "Umami Analytics MCP server for Mintel infrastructure", + "main": "dist/index.js", + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/start.js", + "dev": "tsx watch src/index.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.5.0", + "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/umami-mcp/src/index.ts b/packages/umami-mcp/src/index.ts new file mode 100644 index 0000000..a085931 --- /dev/null +++ b/packages/umami-mcp/src/index.ts @@ -0,0 +1,262 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import express from 'express'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import axios from "axios"; + +const UMAMI_BASE_URL = process.env.UMAMI_BASE_URL || "https://umami.infra.mintel.me"; +const UMAMI_USERNAME = process.env.UMAMI_USERNAME; +const UMAMI_PASSWORD = process.env.UMAMI_PASSWORD; +const UMAMI_API_KEY = process.env.UMAMI_API_KEY; // optional if using API key auth + +if (!UMAMI_USERNAME && !UMAMI_API_KEY) { + console.error("Warning: Neither UMAMI_USERNAME/PASSWORD nor UMAMI_API_KEY is set."); +} + +// Token cache to avoid logging in on every request +let cachedToken: string | null = null; + +async function getAuthHeaders(): Promise> { + if (UMAMI_API_KEY) { + return { 'x-umami-api-key': UMAMI_API_KEY }; + } + if (!cachedToken) { + const res = await axios.post(`${UMAMI_BASE_URL}/api/auth/login`, { + username: UMAMI_USERNAME, + password: UMAMI_PASSWORD, + }); + cachedToken = res.data.token; + } + return { Authorization: `Bearer ${cachedToken}` }; +} + +// --- Tool Definitions --- +const LIST_WEBSITES_TOOL: Tool = { + name: "umami_list_websites", + description: "List all websites tracked in Umami", + inputSchema: { type: "object", properties: {} }, +}; + +const GET_WEBSITE_STATS_TOOL: Tool = { + name: "umami_get_website_stats", + description: "Get summary statistics for a website for a time range", + inputSchema: { + type: "object", + properties: { + website_id: { type: "string", description: "Umami website UUID" }, + start_at: { type: "number", description: "Start timestamp in ms (e.g., Date.now() - 7 days)" }, + end_at: { type: "number", description: "End timestamp in ms (default: now)" }, + }, + required: ["website_id", "start_at"], + }, +}; + +const GET_PAGE_VIEWS_TOOL: Tool = { + name: "umami_get_pageviews", + description: "Get pageview/session time series for a website", + inputSchema: { + type: "object", + properties: { + website_id: { type: "string", description: "Umami website UUID" }, + start_at: { type: "number", description: "Start timestamp in ms" }, + end_at: { type: "number", description: "End timestamp in ms (default: now)" }, + unit: { type: "string", description: "Time unit: 'hour', 'day', 'month' (default: day)" }, + timezone: { type: "string", description: "Timezone (default: Europe/Berlin)" }, + }, + required: ["website_id", "start_at"], + }, +}; + +const GET_TOP_PAGES_TOOL: Tool = { + name: "umami_get_top_pages", + description: "Get the most visited pages/URLs for a website", + inputSchema: { + type: "object", + properties: { + website_id: { type: "string", description: "Umami website UUID" }, + start_at: { type: "number", description: "Start timestamp in ms" }, + end_at: { type: "number", description: "End timestamp in ms" }, + limit: { type: "number", description: "Number of results (default: 20)" }, + }, + required: ["website_id", "start_at"], + }, +}; + +const GET_TOP_REFERRERS_TOOL: Tool = { + name: "umami_get_top_referrers", + description: "Get the top traffic referrers for a website", + inputSchema: { + type: "object", + properties: { + website_id: { type: "string", description: "Umami website UUID" }, + start_at: { type: "number", description: "Start timestamp in ms" }, + end_at: { type: "number", description: "End timestamp in ms" }, + limit: { type: "number", description: "Number of results (default: 10)" }, + }, + required: ["website_id", "start_at"], + }, +}; + +const GET_COUNTRY_STATS_TOOL: Tool = { + name: "umami_get_country_stats", + description: "Get visitor breakdown by country", + inputSchema: { + type: "object", + properties: { + website_id: { type: "string", description: "Umami website UUID" }, + start_at: { type: "number", description: "Start timestamp in ms" }, + end_at: { type: "number", description: "End timestamp in ms" }, + }, + required: ["website_id", "start_at"], + }, +}; + +const GET_ACTIVE_VISITORS_TOOL: Tool = { + name: "umami_get_active_visitors", + description: "Get the number of visitors currently active on a website (last 5 minutes)", + inputSchema: { + type: "object", + properties: { + website_id: { type: "string", description: "Umami website UUID" }, + }, + required: ["website_id"], + }, +}; + +// --- Server Setup --- +const server = new Server( + { name: "umami-mcp", version: "1.0.0" }, + { capabilities: { tools: {} } } +); + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + LIST_WEBSITES_TOOL, + GET_WEBSITE_STATS_TOOL, + GET_PAGE_VIEWS_TOOL, + GET_TOP_PAGES_TOOL, + GET_TOP_REFERRERS_TOOL, + GET_COUNTRY_STATS_TOOL, + GET_ACTIVE_VISITORS_TOOL, + ], +})); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const headers = await getAuthHeaders(); + const api = axios.create({ baseURL: `${UMAMI_BASE_URL}/api`, headers }); + + 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}` }] }; + } + } + + 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}` }] }; + } + } + + 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}` }] }; + } + } + + 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}` }] }; + } + } + + 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}` }] }; + } + } + + 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}` }] }; + } + } + + 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}` }] }; + } + } + + throw new Error(`Unknown tool: ${request.params.name}`); +}); + +// --- Express / SSE Server --- +async function run() { + const app = express(); + let transport: SSEServerTransport | null = null; + + app.get('/sse', async (req, res) => { + console.error('New SSE connection established'); + transport = new SSEServerTransport('/message', res); + await server.connect(transport); + }); + + app.post('/message', async (req, res) => { + if (!transport) { res.status(400).send('No active SSE connection'); return; } + 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`); + }); +} + +run().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/packages/umami-mcp/src/start.ts b/packages/umami-mcp/src/start.ts new file mode 100644 index 0000000..8ce1fb4 --- /dev/null +++ b/packages/umami-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 Umami MCP Server:', err); + process.exit(1); +}); diff --git a/packages/umami-mcp/tsconfig.json b/packages/umami-mcp/tsconfig.json new file mode 100644 index 0000000..0e88912 --- /dev/null +++ b/packages/umami-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 445103e..bc0df2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -843,6 +843,37 @@ importers: specifier: ^3.0.5 version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.33)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) + packages/serpbear-mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.5.0 + 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/thumbnail-generator: dependencies: replicate: @@ -867,6 +898,37 @@ importers: packages/tsconfig: {} + packages/umami-mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.5.0 + 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: '@acemir/cssom@0.9.31':