serpstat-rank-tracker
Version:
Serpstat Rank Tracker API MCP Server
487 lines (481 loc) • 19.6 kB
JavaScript
// 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