feat(mcps): add wiki/packages/releases/projects to gitea + new umami & serpbear MCPs
This commit is contained in:
@@ -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,
|
||||
},
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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<string>();
|
||||
const runStatusCache = new Map<string, string>(); // 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<string, any> = {
|
||||
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<string, any> = { 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}`);
|
||||
});
|
||||
|
||||
|
||||
25
packages/serpbear-mcp/package.json
Normal file
25
packages/serpbear-mcp/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
225
packages/serpbear-mcp/src/index.ts
Normal file
225
packages/serpbear-mcp/src/index.ts
Normal file
@@ -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);
|
||||
});
|
||||
13
packages/serpbear-mcp/src/start.ts
Normal file
13
packages/serpbear-mcp/src/start.ts
Normal file
@@ -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);
|
||||
});
|
||||
16
packages/serpbear-mcp/tsconfig.json
Normal file
16
packages/serpbear-mcp/tsconfig.json
Normal file
@@ -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/**/*"
|
||||
]
|
||||
}
|
||||
25
packages/umami-mcp/package.json
Normal file
25
packages/umami-mcp/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
262
packages/umami-mcp/src/index.ts
Normal file
262
packages/umami-mcp/src/index.ts
Normal file
@@ -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<Record<string, string>> {
|
||||
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);
|
||||
});
|
||||
13
packages/umami-mcp/src/start.ts
Normal file
13
packages/umami-mcp/src/start.ts
Normal file
@@ -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);
|
||||
});
|
||||
16
packages/umami-mcp/tsconfig.json
Normal file
16
packages/umami-mcp/tsconfig.json
Normal file
@@ -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/**/*"
|
||||
]
|
||||
}
|
||||
62
pnpm-lock.yaml
generated
62
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user