metatron-open-seo/src/server/mcp/tools/dataforseo-research-tools.ts

811 lines
25 KiB
TypeScript

/* eslint-disable max-lines */
import { z } from "zod";
import {
createDataforseoClient,
type AdsKeywordItem,
type KeywordOverviewItem,
} from "@/server/lib/dataforseo";
import { getKeywordDataProvider } from "@/shared/keyword-locations";
import { buildProjectMeta } from "@/server/mcp/context";
import { mcpResponse } from "@/server/mcp/formatters";
import {
looseObjectOutputSchema,
optionalMetaOutputSchema,
} from "@/server/mcp/output-schemas";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
languageCodeSchema,
locationCodeSchema,
projectIdSchema,
} from "@/server/mcp/schemas";
const rankedResultTypeSchema = z.enum([
"organic",
"paid",
"featured_snippet",
"local_pack",
"ai_overview_reference",
]);
const serpCompetitorResultTypeSchema = z.enum([
"organic",
"paid",
"featured_snippet",
"local_pack",
]);
const marketSchema = z
.object({
country: z
.enum(["US", "USA", "United States", "United States of America"])
.optional()
.describe("Country selector. Only the United States is supported."),
})
.optional()
.describe("Optional United States market object. Defaults to United States.");
const nearSchema = z
.object({
latitude: z
.number()
.min(-90)
.max(90)
.describe("Latitude of the search center."),
longitude: z
.number()
.min(-180)
.max(180)
.describe("Longitude of the search center."),
radiusKm: z
.number()
.min(1)
.max(100000)
.describe("Search radius around the center, in kilometers."),
})
.describe("Coordinate and radius to search around.");
const localSerpNearSchema = z
.object({
latitude: z
.number()
.min(-90)
.max(90)
.describe("Latitude the SERP is fetched from."),
longitude: z
.number()
.min(-180)
.max(180)
.describe("Longitude the SERP is fetched from."),
zoom: z
.number()
.int()
.min(4)
.max(18)
.optional()
.describe("Map zoom level (4-18). Higher zoom narrows the local area."),
})
.describe("Coordinate (and optional map zoom) the SERP is fetched from.");
const domainTargetSchema = z
.string()
.min(1)
.max(255)
.refine(
(value) =>
/^(?!https?:\/\/)(?!www\.)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(
value,
),
"Use a domain or subdomain without protocol and without www.",
);
const rankedTargetSchema = z
.string()
.min(1)
.max(2048)
.refine(
(value) =>
/^https?:\/\/\S+$/.test(value) ||
domainTargetSchema.safeParse(value).success,
"Use a domain without protocol/www or an absolute page URL.",
);
const getRankedKeywordsInputSchema = {
projectId: projectIdSchema,
target: rankedTargetSchema.describe(
"Domain (no protocol/www) or absolute page URL to list ranked keywords for.",
),
market: marketSchema,
resultTypes: z
.array(rankedResultTypeSchema)
.min(1)
.max(5)
.optional()
.describe("SERP result types to include. Defaults to organic and paid."),
includeSubdomains: z
.boolean()
.optional()
.describe(
"Include subdomains of the target. Defaults to true for domains, false for page URLs.",
),
minSearchVolume: z
.number()
.int()
.min(0)
.optional()
.describe("Only return keywords with at least this monthly search volume."),
maxRank: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe("Only return keywords ranking at this position or better."),
excludeBrandTerms: z
.array(z.string().min(1).max(80))
.min(1)
.max(10)
.optional()
.describe("Exclude keywords containing any of these brand terms."),
sortBy: z
.enum(["rank", "search_volume", "traffic_estimate", "cpc"])
.optional()
.describe("Sort order for returned rows. Defaults to search_volume."),
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe("Maximum rows to return (1-100). Defaults to 50."),
offset: z
.number()
.int()
.min(0)
.max(1000)
.optional()
.describe("Rows to skip for pagination."),
} as const;
const searchLocalBusinessesInputSchema = {
projectId: projectIdSchema,
query: z
.string()
.min(1)
.max(200)
.optional()
.describe("Business name or title text to match."),
near: nearSchema,
categories: z
.array(z.string().min(1).max(120))
.min(1)
.max(10)
.optional()
.describe("Business categories to filter by (e.g. 'pizza_restaurant')."),
limit: z
.number()
.int()
.min(1)
.max(50)
.optional()
.describe("Maximum businesses to return (1-50). Defaults to 20."),
} as const;
const localSearchTypeSchema = z.enum(["maps", "local_finder"]);
const getLocalSerpResultsInputSchema = {
projectId: projectIdSchema,
keyword: z
.string()
.min(1)
.max(120)
.describe("Search query to run on Google Maps or Local Finder."),
near: localSerpNearSchema,
searchType: localSearchTypeSchema
.optional()
.describe("Which local SERP to fetch. Defaults to maps."),
device: z
.enum(["desktop", "mobile"])
.optional()
.describe("Device the SERP is rendered for. Defaults to desktop."),
depth: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe("Number of results to fetch (1-100). Defaults to 20."),
languageCode: languageCodeSchema.optional(),
} as const;
const getGoogleBusinessQuestionsInputSchema = {
projectId: projectIdSchema,
keyword: z
.string()
.min(1)
.max(200)
.describe(
"Business name or search phrase identifying the Google Business Profile.",
),
near: nearSchema,
depth: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe("Maximum Q&A rows to fetch (1-100). Defaults to 20."),
languageCode: languageCodeSchema.optional(),
} as const;
const findSerpCompetitorsInputSchema = {
projectId: projectIdSchema,
keywords: z
.array(z.string().min(1).max(120))
.min(1)
.max(100)
.describe("Keywords whose SERPs are compared (1-100)."),
market: marketSchema,
resultTypes: z
.array(serpCompetitorResultTypeSchema)
.min(1)
.max(4)
.optional()
.describe(
"SERP result types to include. Defaults to organic and local_pack.",
),
excludeDomains: z
.array(domainTargetSchema)
.min(1)
.max(50)
.optional()
.describe("Domains to exclude from results (e.g. the user's own site)."),
includeSubdomains: z
.boolean()
.optional()
.describe("Count subdomains as part of the same competitor domain."),
sortBy: z
.enum(["visibility", "traffic_estimate", "avg_position", "keyword_count"])
.optional()
.describe("Sort order for returned competitors. Defaults to visibility."),
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe("Maximum competitors to return (1-100). Defaults to 50."),
offset: z
.number()
.int()
.min(0)
.max(1000)
.optional()
.describe("Rows to skip for pagination."),
} as const;
const keywordMetricsSortSchema = z.enum([
"search_volume",
"keyword_difficulty",
"cpc",
"competition",
]);
const getKeywordMetricsInputSchema = {
projectId: projectIdSchema,
keywords: z
.array(z.string().min(1).max(80))
.min(1)
.max(700)
.describe("Keywords to fetch metrics for (1-700)."),
locationCode: locationCodeSchema.optional(),
languageCode: languageCodeSchema.optional(),
includeMonthlyTrends: z
.boolean()
.optional()
.describe("Include monthly search-volume trend rows. Defaults to true."),
includeClickstreamData: z
.boolean()
.optional()
.describe(
"Refine search volumes with clickstream data, which disaggregates Google Ads' grouped close-variant volumes (plurals/misspellings). DOUBLES the credit cost of the call. Default false. No effect for countries served from Google Ads data.",
),
sortBy: keywordMetricsSortSchema
.optional()
.describe("Sort order for returned rows. Defaults to search_volume."),
} as const;
type Market = z.infer<typeof marketSchema>;
type GetRankedKeywordsArgs = z.infer<
z.ZodObject<typeof getRankedKeywordsInputSchema>
>;
type FindSerpCompetitorsArgs = z.infer<
z.ZodObject<typeof findSerpCompetitorsInputSchema>
>;
type GetKeywordMetricsArgs = z.infer<
z.ZodObject<typeof getKeywordMetricsInputSchema>
>;
type SearchLocalBusinessesArgs = z.infer<
z.ZodObject<typeof searchLocalBusinessesInputSchema>
>;
type GetLocalSerpResultsArgs = z.infer<
z.ZodObject<typeof getLocalSerpResultsInputSchema>
>;
type GetGoogleBusinessQuestionsArgs = z.infer<
z.ZodObject<typeof getGoogleBusinessQuestionsInputSchema>
>;
const QUESTIONS_ANSWERS_MIN_RADIUS = 200;
const QUESTIONS_ANSWERS_MAX_RADIUS = 199999;
function resolveMarketLocationCode(_market: Market | undefined): number {
// The Zod enum on market.country already restricts values to United States
// variants, so no other country can reach this code path.
return DEFAULT_LOCATION_CODE;
}
function formatCoordinate(value: number): string {
return Number(value.toFixed(7)).toString();
}
function formatBusinessLocationCoordinate(near: z.infer<typeof nearSchema>) {
return `${formatCoordinate(near.latitude)},${formatCoordinate(near.longitude)},${near.radiusKm}`;
}
function formatQuestionsAnswersCoordinate(near: z.infer<typeof nearSchema>) {
const radius = Math.min(
QUESTIONS_ANSWERS_MAX_RADIUS,
Math.max(QUESTIONS_ANSWERS_MIN_RADIUS, Math.round(near.radiusKm * 1000)),
);
return `${formatCoordinate(near.latitude)},${formatCoordinate(near.longitude)},${radius}`;
}
function formatLocalSerpCoordinate(near: z.infer<typeof localSerpNearSchema>) {
const coordinate = `${formatCoordinate(near.latitude)},${formatCoordinate(near.longitude)}`;
return near.zoom == null ? coordinate : `${coordinate},${near.zoom}z`;
}
function sortOrderByRankedMode(
sortBy: GetRankedKeywordsArgs["sortBy"] = "search_volume",
): string[] {
switch (sortBy) {
case "rank":
return ["ranked_serp_element.serp_item.rank_absolute,asc"];
case "traffic_estimate":
return ["ranked_serp_element.serp_item.etv,desc"];
case "cpc":
return ["keyword_data.keyword_info.cpc,desc"];
case "search_volume":
return ["keyword_data.keyword_info.search_volume,desc"];
}
}
function pushAnd(filters: unknown[], condition: unknown[]) {
if (filters.length > 0) filters.push("and");
filters.push(condition);
}
function buildRankedKeywordFilters(args: {
minSearchVolume?: number;
maxRank?: number;
excludeBrandTerms?: string[];
}) {
const filters: unknown[] = [];
if (args.minSearchVolume != null) {
pushAnd(filters, [
"keyword_data.keyword_info.search_volume",
">=",
args.minSearchVolume,
]);
}
if (args.maxRank != null) {
pushAnd(filters, [
"ranked_serp_element.serp_item.rank_absolute",
"<=",
args.maxRank,
]);
}
if (args.excludeBrandTerms != null) {
for (const term of args.excludeBrandTerms) {
pushAnd(filters, ["keyword_data.keyword", "not_ilike", `%${term}%`]);
}
}
return filters.length > 0 ? filters : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return isRecord(value) ? value : undefined;
}
function displayValue(value: unknown): string {
if (typeof value === "string" || typeof value === "number") {
return String(value);
}
return "?";
}
function summarizeRankedKeyword(item: Record<string, unknown>): string {
const keywordData = asRecord(item.keyword_data);
const keywordInfo = asRecord(keywordData?.keyword_info);
const serpElement = asRecord(item.ranked_serp_element);
const serpItem = asRecord(serpElement?.serp_item);
const keyword = displayValue(keywordData?.keyword ?? item.keyword);
const rank = displayValue(
serpItem?.rank_absolute ?? serpElement?.rank_absolute ?? item.rank_absolute,
);
const volume = displayValue(keywordInfo?.search_volume);
const url = displayValue(serpItem?.url ?? serpElement?.url);
return `- "${keyword}" #${rank} vol:${volume} ${url}`.trim();
}
function sortCompetitors(
items: Record<string, unknown>[],
sortBy: FindSerpCompetitorsArgs["sortBy"],
) {
const field =
sortBy === "avg_position"
? "avg_position"
: sortBy === "keyword_count"
? "keywords_count"
: sortBy === "traffic_estimate"
? "etv"
: "visibility";
const direction = sortBy === "avg_position" ? 1 : -1;
return items.toSorted((a, b) => {
const aValue = typeof a[field] === "number" ? a[field] : 0;
const bValue = typeof b[field] === "number" ? b[field] : 0;
return (aValue - bValue) * direction;
});
}
function normalizeKeywordOverview(item: KeywordOverviewItem) {
const info = item.keyword_info;
// Only present when the caller opted into clickstream-refined volumes.
const clickstreamInfo = item.keyword_info_normalized_with_clickstream;
return {
keyword: item.keyword,
search_volume:
clickstreamInfo?.search_volume ?? info?.search_volume ?? null,
keyword_difficulty: item.keyword_properties?.keyword_difficulty ?? null,
main_intent: item.search_intent_info?.main_intent ?? null,
cpc: info?.cpc ?? null,
competition: info?.competition ?? null,
competition_level: info?.competition_level ?? null,
monthly_searches:
(clickstreamInfo?.search_volume
? clickstreamInfo.monthly_searches
: info?.monthly_searches) ?? null,
};
}
type KeywordMetricRow = ReturnType<typeof normalizeKeywordOverview>;
// Google Ads items (countries Labs doesn't cover) have no difficulty/intent.
function normalizeAdsKeyword(item: AdsKeywordItem): KeywordMetricRow {
return {
keyword: item.keyword,
search_volume: item.search_volume ?? null,
keyword_difficulty: null,
main_intent: null,
cpc: item.cpc ?? null,
competition:
item.competition_index != null ? item.competition_index / 100 : null,
competition_level: item.competition ?? null,
monthly_searches: item.monthly_searches ?? null,
};
}
function sortKeywordMetricRows(
rows: KeywordMetricRow[],
sortBy: NonNullable<GetKeywordMetricsArgs["sortBy"]> = "search_volume",
) {
return rows.toSorted((a, b) => {
const aValue = a[sortBy];
const bValue = b[sortBy];
const aNum = typeof aValue === "number" ? aValue : 0;
const bNum = typeof bValue === "number" ? bValue : 0;
return bNum - aNum;
});
}
function hostMatchesDomain(host: string, domain: string): boolean {
const normalizedHost = host.replace(/^www\./, "").toLowerCase();
const normalizedDomain = domain.replace(/^www\./, "").toLowerCase();
return (
normalizedHost === normalizedDomain ||
normalizedHost.endsWith(`.${normalizedDomain}`)
);
}
export const getRankedKeywordsTool = {
name: "get_ranked_keywords",
config: {
title: "Get ranked keywords",
description:
"Returns exact keyword, URL, rank, search volume, CPC, intent, and traffic rows for a domain or page. Use this for strategy evidence; use get_domain_overview for aggregate domain footprint. Charges credits.",
inputSchema: getRankedKeywordsInputSchema,
outputSchema: {
keywords: z.array(looseObjectOutputSchema),
totalCount: z.number().nullable(),
...optionalMetaOutputSchema,
},
annotations: {
readOnlyHint: true,
openWorldHint: false,
destructiveHint: false,
},
},
handler: withMcpProjectAuth(async (args: GetRankedKeywordsArgs, context) => {
const client = createDataforseoClient(context.billing);
const targetIsPage = /^https?:\/\//.test(args.target);
const keywords = await client.domain.rankedKeywords({
target: args.target,
locationCode: resolveMarketLocationCode(args.market),
languageCode: DEFAULT_LANGUAGE_CODE,
limit: args.limit ?? 50,
offset: args.offset,
orderBy: sortOrderByRankedMode(args.sortBy),
filters: buildRankedKeywordFilters({
minSearchVolume: args.minSearchVolume,
maxRank: args.maxRank,
excludeBrandTerms: args.excludeBrandTerms,
}),
itemTypes: args.resultTypes,
includeSubdomains: args.includeSubdomains ?? !targetIsPage,
});
return mcpResponse({
text: [
`Found ${keywords.items.length} ranked keyword rows for ${args.target}.`,
...keywords.items
.slice(0, 10)
.map((item) =>
summarizeRankedKeyword(item as Record<string, unknown>),
),
].join("\n"),
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/domain`,
),
structuredContent: {
keywords: keywords.items,
totalCount: keywords.totalCount,
},
});
}),
};
export const searchLocalBusinessesTool = {
name: "search_local_businesses",
config: {
title: "Search local businesses",
description:
"Searches local business listings near a coordinate. Use this to find local business candidates or nearby competitors; it does not run Maps rank checks or Q&A. Charges credits.",
inputSchema: searchLocalBusinessesInputSchema,
outputSchema: {
businesses: z.array(looseObjectOutputSchema),
...optionalMetaOutputSchema,
},
annotations: {
readOnlyHint: true,
openWorldHint: false,
destructiveHint: false,
},
},
handler: withMcpProjectAuth(
async (args: SearchLocalBusinessesArgs, context) => {
const client = createDataforseoClient(context.billing);
const businesses = await client.business.businessListings({
categories: args.categories,
title: args.query,
locationCoordinate: formatBusinessLocationCoordinate(args.near),
limit: args.limit ?? 20,
});
return mcpResponse({
text: `Found ${businesses.length} local business rows${args.query ? ` for ${args.query}` : ""}.`,
meta: buildProjectMeta(context, args.projectId, `/p/${args.projectId}`),
structuredContent: { businesses },
});
},
),
};
export const getLocalSerpResultsTool = {
name: "get_local_serp_results",
config: {
title: "Get local SERP results",
description:
"Fetches one Google Maps or Local Finder SERP near a coordinate. Returns provider rows with rank fields intact; callers decide how to match a target business. Charges credits.",
inputSchema: getLocalSerpResultsInputSchema,
outputSchema: {
results: z.array(looseObjectOutputSchema),
...optionalMetaOutputSchema,
},
annotations: {
readOnlyHint: true,
openWorldHint: false,
destructiveHint: false,
},
},
handler: withMcpProjectAuth(
async (args: GetLocalSerpResultsArgs, context) => {
const client = createDataforseoClient(context.billing);
const results = await client.serp.local({
keyword: args.keyword,
locationCoordinate: formatLocalSerpCoordinate(args.near),
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
searchType: args.searchType ?? "maps",
device: args.device ?? "desktop",
depth: args.depth ?? 20,
searchPlaces: false,
});
return mcpResponse({
text: `Fetched ${results.length} local SERP rows for "${args.keyword}".`,
meta: buildProjectMeta(context, args.projectId, `/p/${args.projectId}`),
structuredContent: { results },
});
},
),
};
export const getGoogleBusinessQuestionsTool = {
name: "get_google_business_questions",
config: {
title: "Get Google business questions",
description:
"Fetches Google Business Profile questions and answers for one business keyword near a coordinate. Run this only when Q&A evidence is needed. Charges credits.",
inputSchema: getGoogleBusinessQuestionsInputSchema,
outputSchema: {
questions: z.array(looseObjectOutputSchema),
...optionalMetaOutputSchema,
},
annotations: {
readOnlyHint: true,
openWorldHint: false,
destructiveHint: false,
},
},
handler: withMcpProjectAuth(
async (args: GetGoogleBusinessQuestionsArgs, context) => {
const client = createDataforseoClient(context.billing);
const questions = await client.business.questionsAnswers({
keyword: args.keyword,
locationCoordinate: formatQuestionsAnswersCoordinate(args.near),
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
depth: args.depth ?? 20,
});
return mcpResponse({
text: `Fetched ${questions.length} Google Business Q&A rows for ${args.keyword}.`,
meta: buildProjectMeta(context, args.projectId, `/p/${args.projectId}`),
structuredContent: { questions },
});
},
),
};
export const findSerpCompetitorsTool = {
name: "find_serp_competitors",
config: {
title: "Find SERP competitors",
description:
"Compares domains competing in Google results for a supplied keyword set. Useful for market and search-intelligence reports; not radius-based local SEO. Charges credits.",
inputSchema: findSerpCompetitorsInputSchema,
outputSchema: {
competitors: z.array(looseObjectOutputSchema),
...optionalMetaOutputSchema,
},
annotations: {
readOnlyHint: true,
openWorldHint: false,
destructiveHint: false,
},
},
handler: withMcpProjectAuth(
async (args: FindSerpCompetitorsArgs, context) => {
const client = createDataforseoClient(context.billing);
const competitors = await client.labs.serpCompetitors({
keywords: args.keywords,
locationCode: resolveMarketLocationCode(args.market),
languageCode: DEFAULT_LANGUAGE_CODE,
itemTypes: args.resultTypes ?? ["organic", "local_pack"],
includeSubdomains: args.includeSubdomains,
limit: args.limit ?? 50,
offset: args.offset,
});
const excludedDomains = args.excludeDomains ?? [];
const filtered =
excludedDomains.length === 0
? competitors
: competitors.filter((item) => {
const domain = typeof item.domain === "string" ? item.domain : "";
return !excludedDomains.some((excludedDomain) =>
hostMatchesDomain(domain, excludedDomain),
);
});
const sorted = sortCompetitors(filtered, args.sortBy ?? "visibility");
return mcpResponse({
text: `Found ${sorted.length} SERP competitors across ${args.keywords.length} keywords.`,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/domain`,
),
structuredContent: { competitors: sorted },
});
},
),
};
export const getKeywordMetricsTool = {
name: "get_keyword_metrics",
config: {
title: "Get keyword metrics",
description:
"Hydrate up to 700 known keywords with search volume, keyword difficulty (KD), search intent, CPC, competition, and monthly trends in a single call. Use it to score candidate or known keywords — including Search Console striking-distance queries — by real demand and ranking difficulty. For countries served from Google Ads data (e.g. Iceland), KD and intent are null. Charges credits.",
inputSchema: getKeywordMetricsInputSchema,
outputSchema: {
keywords: z.array(looseObjectOutputSchema),
...optionalMetaOutputSchema,
},
annotations: {
readOnlyHint: true,
openWorldHint: false,
destructiveHint: false,
},
},
handler: withMcpProjectAuth(async (args: GetKeywordMetricsArgs, context) => {
const client = createDataforseoClient(context.billing);
const locationCode = args.locationCode ?? DEFAULT_LOCATION_CODE;
const languageCode = args.languageCode ?? DEFAULT_LANGUAGE_CODE;
const normalized =
getKeywordDataProvider(locationCode) === "google_ads"
? (
await client.keywords.adsSearchVolume({
keywords: args.keywords,
locationCode,
languageCode,
creditFeature: "keyword_research",
})
).map(normalizeAdsKeyword)
: (
await client.labs.keywordOverview({
keywords: args.keywords,
locationCode,
languageCode,
includeClickstreamData: args.includeClickstreamData ?? false,
creditFeature: "keyword_research",
})
).map(normalizeKeywordOverview);
const rows = sortKeywordMetricRows(
normalized,
args.sortBy ?? "search_volume",
).map((row) =>
args.includeMonthlyTrends === false
? Object.fromEntries(
Object.entries(row).filter(([key]) => key !== "monthly_searches"),
)
: row,
);
return mcpResponse({
text: `Fetched metrics (volume, difficulty, intent) for ${rows.length} keywords.`,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/keywords`,
),
structuredContent: { keywords: rows },
});
}),
};