UNPKG

serpstat-rank-tracker

Version:

Serpstat Rank Tracker API MCP Server

487 lines (481 loc) 19.6 kB
#!/usr/bin/env node // src/server.ts import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { z as z2 } from "zod"; import { makeSerpstatRequest } from "serpstat-shared"; // src/constants.ts var API_METHODS = { // Project Management Methods getProjects: "RtApiProjectProcedure.getProjects", getProjectStatus: "RtApiProjectProcedure.getProjectStatus", getProjectRegions: "RtApiSearchEngineProcedure.getProjectRegions", // SERP Results Methods getKeywordsSerpResultsHistory: "RtApiSerpResultsProcedure.getKeywordsSerpResultsHistory", getUrlsSerpResultsHistory: "RtApiSerpResultsProcedure.getUrlsSerpResultsHistory", getTopCompetitorsDomainsHistory: "RtApiSerpResultsProcedure.getTopCompetitorsDomainsHistory" }; var PAGE_SIZE_OPTIONS = [20, 50, 100, 200, 500]; var SORT_OPTIONS = ["keyword", "date"]; var COMPETITORS_SORT_OPTIONS = [ "domain", "sum_traffic", "keywords_count", "avg_position", "position_ranges", "ads_count" ]; var SORT_RANGE_OPTIONS = [ "top1", "top2", "top3", "top5", "top10", "top20", "top101", "keywords_count_bottom", "keywords_count_top", "avg_position_top", "avg_position_bottom" ]; var ORDER_OPTIONS = ["asc", "desc"]; var API_LIMITS = { MAX_PAGE_SIZE: 500, MAX_KEYWORDS_FILTER: 1e3, MAX_RESULTS_PER_PAGE: 1e3 }; var DATE_PATTERN = /^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$/; // src/utils/validation.ts import { z } from "zod"; var ProjectIdSchema = z.object({ projectId: z.number().int("Project ID must be an integer").min(1, "Project ID must be at least 1").describe("The unique identifier for a rank tracker project") }); var RegionIdSchema = z.object({ regionId: z.number().int("Region ID must be an integer").min(1, "Region ID must be at least 1").describe("The unique identifier for a search region") }); var ProjectRegionIdSchema = z.object({ projectRegionId: z.number().int("Project Region ID must be an integer").min(1, "Project Region ID must be at least 1").describe("The unique identifier for a project region configuration") }); var PaginationSchema = z.object({ page: z.number().int("Page must be an integer").min(1, "Page must be at least 1").describe("Page number in response"), pageSize: z.number().int("Page size must be an integer").min(1, "Page size must be at least 1").max(API_LIMITS.MAX_PAGE_SIZE, `Page size cannot exceed ${API_LIMITS.MAX_PAGE_SIZE}`).refine( (size) => PAGE_SIZE_OPTIONS.includes(size), `Page size must be one of: ${PAGE_SIZE_OPTIONS.join(", ")}` ).describe("Number of results per page") }); var DateRangeSchema = z.object({ dateFrom: z.string().regex(DATE_PATTERN, "Date from must be in YYYY-MM-DD format").optional().describe("Start date for historical data (YYYY-MM-DD)"), dateTo: z.string().regex(DATE_PATTERN, "Date to must be in YYYY-MM-DD format").optional().describe("End date for historical data (YYYY-MM-DD)") }); var SortSchema = z.object({ sort: z.enum(SORT_OPTIONS).optional().describe("Sorting by field"), order: z.enum(ORDER_OPTIONS).optional().describe("Sorting order") }); var GetProjectsSchema = z.object({ page: z.number().int("Page must be an integer").min(1, "Page must be at least 1").optional().default(1).describe("Page number in response (Default: 1)"), pageSize: z.number().int("Page size must be an integer").min(1, "Page size must be at least 1").max(500, "Page size cannot exceed 500").refine( (size) => [20, 50, 100, 500].includes(size), "Page size must be one of: 20, 50, 100, 500" ).optional().default(100).describe("Number of results per page (Default: 100)") }); var GetProjectStatusSchema = ProjectIdSchema.merge(RegionIdSchema); var GetProjectRegionsSchema = ProjectIdSchema; var GetKeywordsSerpResultsHistorySchema = ProjectIdSchema.merge(ProjectRegionIdSchema).merge(PaginationSchema.pick({ page: true })).merge(DateRangeSchema).merge(SortSchema).extend({ pageSize: z.number().int("Page size must be an integer").min(1, "Page size must be at least 1").max(500, "Page size cannot exceed 500").refine( (size) => [20, 50, 100, 200, 500].includes(size), "Page size must be one of: 20, 50, 100, 200, 500" ).optional().default(100).describe("Number of results per page (Default: 100)"), keywords: z.array(z.string()).max(API_LIMITS.MAX_KEYWORDS_FILTER, `Cannot filter more than ${API_LIMITS.MAX_KEYWORDS_FILTER} keywords`).optional().describe("Keywords for which pages and positions are required"), withTags: z.boolean().optional().default(false).describe("Display tags for the keywords (Default: false)") }); var GetUrlsSerpResultsHistorySchema = ProjectIdSchema.merge(ProjectRegionIdSchema).merge(PaginationSchema.pick({ page: true })).merge(DateRangeSchema).merge(SortSchema).extend({ pageSize: z.number().int("Page size must be an integer").min(1, "Page size must be at least 1").max(500, "Page size cannot exceed 500").refine( (size) => [20, 50, 100, 200, 500].includes(size), "Page size must be one of: 20, 50, 100, 200, 500" ).optional().default(100).describe("Number of results per page (Default: 100)"), keywords: z.array(z.string()).max(API_LIMITS.MAX_KEYWORDS_FILTER, `Cannot filter more than ${API_LIMITS.MAX_KEYWORDS_FILTER} keywords`).optional().describe("Keywords for which pages and positions are required"), withTags: z.boolean().optional().default(false).describe("Display tags for the keywords (Default: false)"), domain: z.string().min(1, "Domain must be at least 1 character").optional().describe("Domain or page for which the data is required (format: domain.com or https://domain.com/)") }); var GetTopCompetitorsDomainsHistorySchema = ProjectIdSchema.merge(ProjectRegionIdSchema).merge(PaginationSchema).extend({ dateFrom: z.string().regex(DATE_PATTERN, "Date from must be in YYYY-MM-DD format").describe("Start date of the period for which the data is required"), dateTo: z.string().regex(DATE_PATTERN, "Date to must be in YYYY-MM-DD format").describe("Date of last withdrawal of positions"), sort: z.enum(COMPETITORS_SORT_OPTIONS).optional().default("sum_traffic").describe("Sorting by parameters"), sortRange: z.enum(SORT_RANGE_OPTIONS).optional().describe("Sort range for detailed sorting"), order: z.enum(ORDER_OPTIONS).optional().default("desc").describe("Sorting order"), domains: z.array(z.string()).min(1, "At least one domain must be provided").describe("All domains in top 20 for two project keywords") }); // src/server.ts var server = new Server( { name: "serpstat-rank-tracker", version: "0.1.0" }, { capabilities: { tools: {} } } ); var TOOLS = [ { name: "getProjects", description: "Get a list of your rank tracker projects with ID, name, domain, creation date, and status. Essential for project management and identifying available projects for analysis. (No API credits consumed)", inputSchema: { type: "object", properties: { page: { type: "number", description: "Page number in response (Default: 1)" }, pageSize: { type: "number", enum: [20, 50, 100, 500], description: "Number of results per page (Default: 100)" } }, required: [] } }, { name: "getProjectStatus", description: "Get the current status of position update process (parsing) for a project and region. Check if data is ready before making other API calls to ensure accurate results. (No API credits consumed)", inputSchema: { type: "object", properties: { projectId: { type: "number", description: "Project identifier in numeric representation" }, regionId: { type: "number", description: "Search region ID" } }, required: ["projectId", "regionId"] } }, { name: "getProjectRegions", description: "Get the list of project regions and their status. Essential for understanding which regions are configured for tracking and their current settings. (No API credits consumed)", inputSchema: { type: "object", properties: { projectId: { type: "number", description: "Project ID" } }, required: ["projectId"] } }, { name: "getKeywordsSerpResultsHistory", description: "Get Google's top-100 search results for project keywords in a specific region. Perfect for analyzing keyword performance, ranking trends, and SERP positioning over time. (No API credits consumed)", inputSchema: { type: "object", properties: { projectId: { type: "number", description: "Project ID" }, projectRegionId: { type: "number", description: "Region ID" }, page: { type: "number", description: "Page number (Default: 1)" }, pageSize: { type: "number", enum: [20, 50, 100, 200, 500], description: "Number of results per page (Default: 100)" }, dateFrom: { type: "string", description: "Start date for historical data (YYYY-MM-DD)" }, dateTo: { type: "string", description: "End date for historical data (YYYY-MM-DD)" }, sort: { type: "string", enum: ["keyword", "date"], description: "Sorting by field (Default: keyword)" }, order: { type: "string", enum: ["asc", "desc"], description: "Sorting order (Default: desc)" }, keywords: { type: "array", items: { type: "string" }, description: "Keywords for which pages and positions are required (max 1000)" }, withTags: { type: "boolean", description: "Display tags for the keywords (Default: false)" } }, required: ["projectId", "projectRegionId", "page"] } }, { name: "getUrlsSerpResultsHistory", description: "Get the ranking history of URLs for project keywords in a specific region. Ideal for tracking specific page performance, URL ranking changes, and competitor URL analysis. (No API credits consumed)", inputSchema: { type: "object", properties: { projectId: { type: "number", description: "Project ID" }, projectRegionId: { type: "number", description: "Region ID" }, page: { type: "number", description: "Page number (Default: 1)" }, pageSize: { type: "number", enum: [20, 50, 100, 200, 500], description: "Number of results per page (Default: 100)" }, dateFrom: { type: "string", description: "Start date for historical data (YYYY-MM-DD)" }, dateTo: { type: "string", description: "End date for historical data (YYYY-MM-DD)" }, sort: { type: "string", enum: ["keyword", "date"], description: "Sorting by field (Default: keyword)" }, order: { type: "string", enum: ["asc", "desc"], description: "Sorting order (Default: desc)" }, keywords: { type: "array", items: { type: "string" }, description: "Keywords for which pages and positions are required (max 1000)" }, withTags: { type: "boolean", description: "Display tags for the keywords (Default: false)" }, domain: { type: "string", description: "Domain or page for which the data is required (format: domain.com or https://domain.com/)" } }, required: ["projectId", "projectRegionId", "page"] } }, { name: "getTopCompetitorsDomainsHistory", description: "Get domains listed in top-20 for project keywords with historical data. Essential for competitive analysis, identifying new competitors, and tracking competitor ranking trends. (No API credits consumed)", inputSchema: { type: "object", properties: { projectId: { type: "number", description: "Project ID" }, projectRegionId: { type: "number", description: "Region ID" }, page: { type: "number", description: "Page number" }, pageSize: { type: "number", minimum: 20, maximum: 500, description: "Number of results per page (20-500, Default: 100)" }, dateFrom: { type: "string", description: "Start date of the period for which the data is required (YYYY-MM-DD)" }, dateTo: { type: "string", description: "Date of last withdrawal of positions (YYYY-MM-DD)" }, sort: { type: "string", enum: ["domain", "sum_traffic", "keywords_count", "avg_position", "position_ranges", "ads_count"], description: "Sorting by parameters (Default: sum_traffic)" }, sortRange: { type: "string", enum: ["top1", "top2", "top3", "top5", "top10", "top20", "top101", "keywords_count_bottom", "keywords_count_top", "avg_position_top", "avg_position_bottom"], description: "Sort range for detailed sorting" }, order: { type: "string", enum: ["asc", "desc"], description: "Sorting order (Default: desc)" }, domains: { type: "array", items: { type: "string" }, description: "All domains in top 20 for two project keywords" } }, required: ["projectId", "projectRegionId", "page", "pageSize", "dateFrom", "dateTo", "domains"] } } ]; async function handleGetProjects(args) { const validatedArgs = GetProjectsSchema.parse(args); return await makeSerpstatRequest(API_METHODS.getProjects, { ...validatedArgs.page && validatedArgs.page !== 1 && { page: validatedArgs.page }, ...validatedArgs.pageSize && validatedArgs.pageSize !== 100 && { pageSize: validatedArgs.pageSize } }); } async function handleGetProjectStatus(args) { const validatedArgs = GetProjectStatusSchema.parse(args); return await makeSerpstatRequest(API_METHODS.getProjectStatus, { projectId: validatedArgs.projectId, regionId: validatedArgs.regionId }); } async function handleGetProjectRegions(args) { const validatedArgs = GetProjectRegionsSchema.parse(args); return await makeSerpstatRequest(API_METHODS.getProjectRegions, { projectId: validatedArgs.projectId }); } async function handleGetKeywordsSerpResultsHistory(args) { const validatedArgs = GetKeywordsSerpResultsHistorySchema.parse(args); return await makeSerpstatRequest(API_METHODS.getKeywordsSerpResultsHistory, { projectId: validatedArgs.projectId, projectRegionId: validatedArgs.projectRegionId, page: validatedArgs.page, ...validatedArgs.pageSize && validatedArgs.pageSize !== 100 && { pageSize: validatedArgs.pageSize }, ...validatedArgs.dateFrom && { dateFrom: validatedArgs.dateFrom }, ...validatedArgs.dateTo && { dateTo: validatedArgs.dateTo }, ...validatedArgs.sort && validatedArgs.sort !== "keyword" && { sort: validatedArgs.sort }, ...validatedArgs.order && validatedArgs.order !== "desc" && { order: validatedArgs.order }, ...validatedArgs.keywords && validatedArgs.keywords.length > 0 && { keywords: validatedArgs.keywords }, ...validatedArgs.withTags && validatedArgs.withTags !== false && { withTags: validatedArgs.withTags } }); } async function handleGetUrlsSerpResultsHistory(args) { const validatedArgs = GetUrlsSerpResultsHistorySchema.parse(args); return await makeSerpstatRequest(API_METHODS.getUrlsSerpResultsHistory, { projectId: validatedArgs.projectId, projectRegionId: validatedArgs.projectRegionId, page: validatedArgs.page, ...validatedArgs.pageSize && validatedArgs.pageSize !== 100 && { pageSize: validatedArgs.pageSize }, ...validatedArgs.dateFrom && { dateFrom: validatedArgs.dateFrom }, ...validatedArgs.dateTo && { dateTo: validatedArgs.dateTo }, ...validatedArgs.sort && validatedArgs.sort !== "keyword" && { sort: validatedArgs.sort }, ...validatedArgs.order && validatedArgs.order !== "desc" && { order: validatedArgs.order }, ...validatedArgs.keywords && validatedArgs.keywords.length > 0 && { keywords: validatedArgs.keywords }, ...validatedArgs.withTags && validatedArgs.withTags !== false && { withTags: validatedArgs.withTags }, ...validatedArgs.domain && { domain: validatedArgs.domain } }); } async function handleGetTopCompetitorsDomainsHistory(args) { const validatedArgs = GetTopCompetitorsDomainsHistorySchema.parse(args); return await makeSerpstatRequest(API_METHODS.getTopCompetitorsDomainsHistory, { projectId: validatedArgs.projectId, projectRegionId: validatedArgs.projectRegionId, page: validatedArgs.page, pageSize: validatedArgs.pageSize, dateFrom: validatedArgs.dateFrom, dateTo: validatedArgs.dateTo, ...validatedArgs.sort && validatedArgs.sort !== "sum_traffic" && { sort: validatedArgs.sort }, ...validatedArgs.sortRange && { sortRange: validatedArgs.sortRange }, ...validatedArgs.order && validatedArgs.order !== "desc" && { order: validatedArgs.order }, domains: validatedArgs.domains }); } server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: TOOLS }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { let result; switch (name) { case "getProjects": result = await handleGetProjects(args); break; case "getProjectStatus": result = await handleGetProjectStatus(args); break; case "getProjectRegions": result = await handleGetProjectRegions(args); break; case "getKeywordsSerpResultsHistory": result = await handleGetKeywordsSerpResultsHistory(args); break; case "getUrlsSerpResultsHistory": result = await handleGetUrlsSerpResultsHistory(args); break; case "getTopCompetitorsDomainsHistory": result = await handleGetTopCompetitorsDomainsHistory(args); break; default: throw new Error(`Unknown tool: ${name}`); } return { content: [ { type: "text", text: JSON.stringify(result, null, 2) } ] }; } catch (error) { if (error instanceof z2.ZodError) { throw new Error( `Invalid arguments for ${name}: ${error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ")}` ); } throw error; } }); async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Serpstat Rank Tracker MCP server started"); } main().catch((error) => { console.error("Server error:", error); process.exit(1); }); // src/index.ts if (import.meta.url === `file://${process.argv[1]}`) { console.error("Serpstat Rank Tracker MCP server entry point loaded"); } export { server }; //# sourceMappingURL=index.js.map