/* 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; type GetRankedKeywordsArgs = z.infer< z.ZodObject >; type FindSerpCompetitorsArgs = z.infer< z.ZodObject >; type GetKeywordMetricsArgs = z.infer< z.ZodObject >; type SearchLocalBusinessesArgs = z.infer< z.ZodObject >; type GetLocalSerpResultsArgs = z.infer< z.ZodObject >; type GetGoogleBusinessQuestionsArgs = z.infer< z.ZodObject >; 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) { return `${formatCoordinate(near.latitude)},${formatCoordinate(near.longitude)},${near.radiusKm}`; } function formatQuestionsAnswersCoordinate(near: z.infer) { 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) { 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 { return typeof value === "object" && value !== null && !Array.isArray(value); } function asRecord(value: unknown): Record | 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 { 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[], 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; // 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 = "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), ), ].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 }, }); }), };