|
|
|
@@ -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);
|
|
|
|
|
});
|