Add additional MCP tools for Local SEO (#222)

This commit is contained in:
Ben Senescu 2026-05-26 18:38:14 -04:00 committed by GitHub
parent f95c66b7f2
commit 506dde84d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1614 additions and 62 deletions

View File

@ -21,27 +21,33 @@ Use this when the user wants a market-level view across several competitors. For
## OpenSEO MCP tools ## OpenSEO MCP tools
- `research_keywords`: discover representative market queries. - `research_keywords`: discover representative market queries.
- `get_keyword_search_volume`: validate known query sets and trend/metric priority.
- `get_serp_results`: identify recurring ranking domains across target queries. - `get_serp_results`: identify recurring ranking domains across target queries.
- `find_serp_competitors`: compare domains competing across supplied keywords; use this before manual SERP counting when a keyword set is available.
- `get_domain_overview`: size organic footprint for candidate leaders. - `get_domain_overview`: size organic footprint for candidate leaders.
- `get_domain_keyword_suggestions`: find what each leader ranks for. - `get_ranked_keywords`: find exact ranking keywords, URLs, ranks, intents, and SERP result types for leaders.
- `get_backlinks_overview`: compare backlink/referring-domain strength where relevant. - `get_backlinks_overview`: compare backlink/referring-domain strength where relevant.
- `search_local_businesses`, `get_local_serp_results`, and `get_google_business_questions`: use for local SEO markets where proximity, Maps rankings, business categories, reviews, or Google Q&A affect who is winning.
## Workflow ## Workflow
1. Define the market query set: 1. Define the market query set:
- Use provided keywords, or call `research_keywords` to build 5-10 representative queries. - Use provided keywords, or call `research_keywords` to build 5-10 representative queries.
- Include mixed intent: informational, commercial, comparison, and tool/software terms when applicable. - Include mixed intent: informational, commercial, comparison, and tool/software terms when applicable.
2. Call `get_serp_results` for the representative queries. Send at most 10 queries per call. - For local SEO, include neighborhood/city/service-area queries and identify the priority locations or coordinates.
3. Identify recurring domains and group them by type: 2. If the query set is already known, use `get_keyword_search_volume` to validate relative demand and `find_serp_competitors` to identify recurring domains at scale.
3. For local SEO, call `search_local_businesses` and `get_local_serp_results` for the highest-priority location(s) before synthesizing winners. Use `get_serp_results` as a complement for organic pages, not as the only local evidence.
4. Call `get_serp_results` for representative queries when live SERP composition, ranking URLs, or SERP features need inspection. Send at most 10 queries per call.
5. Identify recurring domains and group them by type:
- Direct product competitors - Direct product competitors
- Publishers/media - Publishers/media
- Marketplaces/directories - Marketplaces/directories
- Communities/forums - Communities/forums
- Documentation/resources - Documentation/resources
4. For the strongest recurring domains, call `get_domain_overview`; default to the top 3-5 domains before expanding. 6. For the strongest recurring domains, call `get_domain_overview`; default to the top 3-5 domains before expanding.
5. For direct competitors and relevant publishers, call `get_domain_keyword_suggestions`; default to the top 3-5 domains before expanding. 7. For direct competitors and relevant publishers, call `get_ranked_keywords`.
6. Use `get_backlinks_overview` when backlink authority appears important or the user asks why a domain is winning. Backlinks may be unavailable if the account has not enabled that data; continue with SERP/domain evidence if it fails. 8. Use `get_backlinks_overview` when backlink authority appears important or the user asks why a domain is winning. Backlinks may be unavailable if the account has not enabled that data; continue with SERP/domain evidence if it fails.
7. Synthesize patterns: content types, themes, SERP formats, authority advantages, and underserved angles. 9. Synthesize patterns: content types, themes, SERP formats, local-pack signals, authority advantages, and underserved angles.
## Output format ## Output format
@ -70,3 +76,4 @@ Add:
- Do not overstate exact traffic when OpenSEO returns estimates. - Do not overstate exact traffic when OpenSEO returns estimates.
- If using a small query set, call the result directional. - If using a small query set, call the result directional.
- Do not assume a publisher is a product competitor; label domain types clearly. - Do not assume a publisher is a product competitor; label domain types clearly.
- For local markets, distinguish organic-page winners from Maps/local-pack winners.

View File

@ -21,8 +21,10 @@ Use this for a named competitor. For identifying the market leaders first, use `
## OpenSEO MCP tools ## OpenSEO MCP tools
- `get_domain_overview`: baseline organic traffic and keyword count. - `get_domain_overview`: baseline organic traffic and keyword count.
- `get_domain_keyword_suggestions`: top ranking keywords and keyword themes. - `get_ranked_keywords`: exact keyword, URL, rank, intent, traffic, CPC, and SERP-type rows for the competitor domain or page.
- `get_backlinks_overview`: backlink/referring-domain profile. - `get_backlinks_overview`: backlink/referring-domain profile.
- `find_serp_competitors`: validate whether the named competitor is a real search competitor across the target keyword set.
- `search_local_businesses`, `get_local_serp_results`, and `get_google_business_questions`: use for local SEO competitors when Maps/local-pack visibility, nearby businesses, categories, or Google Q&A matter.
- `get_serp_results`: validate direct head-to-head SERPs for important keywords. - `get_serp_results`: validate direct head-to-head SERPs for important keywords.
- `research_keywords`: expand gaps or category terms when needed. - `research_keywords`: expand gaps or category terms when needed.
@ -30,16 +32,20 @@ Use this for a named competitor. For identifying the market leaders first, use `
1. Call `get_domain_overview` for the competitor, passing provided location/language when supported. 1. Call `get_domain_overview` for the competitor, passing provided location/language when supported.
2. If comparing to the user, call `get_domain_overview` for the user's domain too. 2. If comparing to the user, call `get_domain_overview` for the user's domain too.
3. Call `get_domain_keyword_suggestions` for the competitor, passing provided location/language when supported. 3. Call `get_ranked_keywords` for the competitor. Use filters like `maxRank`, `minSearchVolume`, `excludeBrandTerms`, and `resultTypes` to keep rows relevant.
4. Group competitor keywords into themes: 4. If comparing to the user, call `get_ranked_keywords` for the user's domain/page too, or use `get_serp_results` for the shared terms when a lighter check is enough.
5. For local SEO, use `search_local_businesses` and `get_local_serp_results` around the relevant business location(s) before drawing local-pack conclusions. Add `get_google_business_questions` only when Q&A evidence matters.
6. Use `find_serp_competitors` when the competitor was supplied by the user but its search overlap is unclear.
7. Group competitor keywords into themes:
- Product/category terms - Product/category terms
- Alternatives/comparisons - Alternatives/comparisons
- Templates/tools/calculators - Templates/tools/calculators
- Educational guides - Educational guides
- Branded demand - Branded demand
5. Call `get_backlinks_overview` for the competitor, especially if authority appears to explain rankings. Continue without backlink evidence if it is unavailable. - Local/neighborhood terms when relevant
6. Use `get_serp_results` for important shared or target keywords to compare positioning, passing provided location/language when supported. 8. Call `get_backlinks_overview` for the competitor, especially if authority appears to explain rankings. Continue without backlink evidence if it is unavailable.
7. Produce an actionable plan: 9. Use `get_serp_results` for important shared or target keywords to compare positioning, passing provided location/language when supported.
10. Produce an actionable plan:
- What they are doing well - What they are doing well
- Where they are vulnerable - Where they are vulnerable
- Which pages/keywords to pursue - Which pages/keywords to pursue
@ -71,5 +77,6 @@ Include sections for:
- Do not treat all competitor keywords as desirable. Filter for business fit. - Do not treat all competitor keywords as desirable. Filter for business fit.
- Separate evidence from inference. - Separate evidence from inference.
- Do not infer competitor page/content-type patterns from keyword rows alone; use SERP or web evidence for page-level claims. - Do not infer competitor page/content-type patterns from keyword rows alone; use SERP or web evidence for page-level claims.
- For local SEO, do not infer Maps/local-pack strength from national organic domain metrics alone; use local business and local SERP tools when the location is known or reasonably discoverable.
- Do not recommend copying content; recommend a stronger angle or better answer to the same intent. - Do not recommend copying content; recommend a stronger angle or better answer to the same intent.
- If the user's domain is unavailable, frame the analysis as competitor-only. - If the user's domain is unavailable, frame the analysis as competitor-only.

View File

@ -15,19 +15,22 @@ Group keywords into page-level clusters and decide which existing or new page sh
- A keyword list, saved keyword tag, seed topic, or target domain - A keyword list, saved keyword tag, seed topic, or target domain
- Optional existing URLs/pages to map against - Optional existing URLs/pages to map against
If keywords are not provided, use `list_saved_keywords` for saved sets, `research_keywords` for seed discovery, or `get_domain_keyword_suggestions` when the user starts from a target domain. If keywords are not provided, use `list_saved_keywords` for saved sets, `research_keywords` for seed discovery, or `get_ranked_keywords` when the user starts from a target domain.
## OpenSEO MCP tools ## OpenSEO MCP tools
- `list_saved_keywords`: fetch an existing keyword set, optionally filtered by tags. - `list_saved_keywords`: fetch an existing keyword set, optionally filtered by tags.
- `research_keywords`: expand a seed when the user starts from a topic. - `research_keywords`: expand a seed when the user starts from a topic.
- `get_domain_keyword_suggestions`: gather ranking keywords when the user starts from a domain. - `get_ranked_keywords`: gather exact ranking keywords and URLs when the user starts from a domain or page.
- `get_serp_results`: validate whether keywords belong on the same page by checking SERP overlap and intent. - `get_serp_results`: validate whether keywords belong on the same page by checking SERP overlap and intent.
- `get_local_serp_results`: use for local SEO clusters when Maps/local-pack intent should affect page mapping.
- `save_keywords`: optionally tag final clusters after user confirmation. - `save_keywords`: optionally tag final clusters after user confirmation.
## Workflow ## Workflow
1. Gather the candidate keyword set. 1. Gather the candidate keyword set.
- Use `get_ranked_keywords` for domain/page-driven clustering.
- Use `search_local_businesses` and `get_local_serp_results` when proximity, local packs, or Google Business results determine whether terms belong on location pages.
2. Remove duplicates, irrelevant terms, and terms that clearly require a different product or audience. 2. Remove duplicates, irrelevant terms, and terms that clearly require a different product or audience.
3. Build clusters around intent and page type: 3. Build clusters around intent and page type:
- Same SERP intent and similar ranking pages belong together. - Same SERP intent and similar ranking pages belong together.

View File

@ -20,24 +20,31 @@ If `projectId` is missing, use `list_projects` first. If the target market/locat
## OpenSEO MCP tools ## OpenSEO MCP tools
- `research_keywords`: primary discovery tool. Use 1-5 seeds per call and prefer 150 results unless the user asks for exhaustive research. - `research_keywords`: primary discovery tool. Use 1-5 seeds per call and prefer 150 results unless the user asks for exhaustive research.
- `get_keyword_search_volume`: validate known keywords, compare CPC/competition, or refresh monthly trends without broad discovery.
- `get_ranked_keywords`: pull exact ranking keyword rows when a target domain or page is part of the research brief.
- `get_serp_results`: inspect SERPs for the top candidate terms, especially when intent is ambiguous. - `get_serp_results`: inspect SERPs for the top candidate terms, especially when intent is ambiguous.
- `search_local_businesses`, `get_local_serp_results`, and `get_google_business_questions`: use for local SEO topics when a business/location radius matters.
- `list_saved_keywords`: avoid duplicating already-saved work or use existing tags as context. - `list_saved_keywords`: avoid duplicating already-saved work or use existing tags as context.
- `save_keywords`: save selected keywords only after explicit user confirmation. - `save_keywords`: save selected keywords only after explicit user confirmation.
## Workflow ## Workflow
1. Normalize seeds into a small set of distinct research angles. 1. Normalize seeds into a small set of distinct research angles.
2. Call `research_keywords` for the seeds. Use bulk calls when possible. 2. If the request is local SEO, identify the business, location/coordinates or service area, and local categories. Use `search_local_businesses` and `get_local_serp_results` for the most important location/keyword set instead of relying only on national keyword/SERP data.
3. Remove irrelevant, duplicate, branded-only, and off-intent terms. 3. Call `research_keywords` for exploratory seeds. Use bulk calls when possible.
4. Prioritize by practical opportunity, not volume alone: 4. Use `get_keyword_search_volume` when the user provides a fixed keyword list or when exact known-term metrics/trends are more useful than related-keyword expansion.
5. Use `get_ranked_keywords` when the user provides a domain/page and wants opportunities based on current rankings, near-misses, or competitor-owned terms.
6. Remove irrelevant, duplicate, branded-only, and off-intent terms.
7. Prioritize by practical opportunity, not volume alone:
- Strong match to the user's product/page/topic - Strong match to the user's product/page/topic
- Clear search intent - Clear search intent
- Reasonable difficulty - Reasonable difficulty
- Useful volume/CPC signal - Useful volume/CPC signal
- SERP where the user can plausibly compete - SERP where the user can plausibly compete
5. Use `get_serp_results` for high-potential or ambiguous keywords when SERP intent would change the recommendation; keep the default check small. - For local SEO, local-pack/Maps visibility and proximity fit
6. Present a shortlist and a longer opportunity table. 8. Use `get_serp_results` for high-potential or ambiguous keywords when SERP intent would change the recommendation; keep the default check small.
7. Ask before saving keywords. When saving, suggest concise tags such as `topic:<topic>`, `intent:<intent>`, or `page:<slug>`. 9. Present a shortlist and a longer opportunity table.
10. Ask before saving keywords. When saving, suggest concise tags such as `topic:<topic>`, `intent:<intent>`, or `page:<slug>`.
## Output format ## Output format

View File

@ -22,7 +22,8 @@ Find realistic pages, sites, and authors that might reference the user's page, p
- `get_serp_results`: find ranking articles, listicles, resource pages, comparisons, and topical publishers. - `get_serp_results`: find ranking articles, listicles, resource pages, comparisons, and topical publishers.
- `get_backlinks_overview`: inspect competitor domain or page backlink/referring-domain patterns. - `get_backlinks_overview`: inspect competitor domain or page backlink/referring-domain patterns.
- `get_domain_overview`: qualify important prospect domains. - `get_domain_overview`: qualify important prospect domains.
- `get_domain_keyword_suggestions`: understand what a prospect or competitor is known for. - `get_ranked_keywords`: understand what a prospect or competitor ranks for when topical fit matters.
- `search_local_businesses` and `get_local_serp_results`: use for local SEO link prospecting when nearby businesses, local competitors, or Maps/category signals can reveal partnership targets.
- `research_keywords`: expand prospecting queries. - `research_keywords`: expand prospecting queries.
## Contact discovery tools ## Contact discovery tools
@ -64,18 +65,19 @@ Use `get_serp_results` in batches for the most relevant patterns. Send at most 1
2. Build 5-10 prospecting queries by default. 2. Build 5-10 prospecting queries by default.
3. Call `get_serp_results` for those queries. 3. Call `get_serp_results` for those queries.
4. If competitors are provided, call `get_backlinks_overview` for the strongest competitor domains or pages first. Continue without backlink evidence if it is unavailable. 4. If competitors are provided, call `get_backlinks_overview` for the strongest competitor domains or pages first. Continue without backlink evidence if it is unavailable.
5. Filter prospects: 5. For local SEO, use `search_local_businesses` and `get_local_serp_results` around priority locations to identify nearby competitors, categories, and local SERP evidence before searching for local chambers, associations, campus resources, community pages, and directories.
6. Filter prospects:
- Keep topical relevance and editorial pages. - Keep topical relevance and editorial pages.
- Prioritize articles, directories, resource pages, comparisons, statistics pages, templates, and curated lists. - Prioritize articles, directories, resource pages, comparisons, statistics pages, templates, and curated lists.
- Deprioritize homepages, login pages, thin affiliate pages, spam, unrelated forums, and direct competitors unless a comparison angle is valid. - Deprioritize homepages, login pages, thin affiliate pages, spam, unrelated forums, and direct competitors unless a comparison angle is valid.
6. For each good prospect, define the outreach angle: 7. For each good prospect, define the outreach angle:
- Broken/missing resource - Broken/missing resource
- Better current data - Better current data
- Useful tool/template - Useful tool/template
- Alternative or comparison inclusion - Alternative or comparison inclusion
- Expert quote or supporting reference - Expert quote or supporting reference
7. For the strongest prospects, visit or search the prospect site to find the best contact path. 8. For the strongest prospects, visit or search the prospect site to find the best contact path.
8. Draft outreach messages. If contact details were found, include the source. If not, list the next best contact-discovery path. 9. Draft outreach messages. If contact details were found, include the source. If not, list the next best contact-discovery path.
## Output format ## Output format

View File

@ -48,7 +48,7 @@ Good starting points:
Explain the difference between data sources: Explain the difference between data sources:
- OpenSEO MCP tools provide SEO data such as keywords, SERPs, domain overviews, backlinks, saved keywords, projects, and rank trackers. - OpenSEO MCP tools provide SEO data such as keyword research, exact ranked keywords, search volume, SERPs, SERP competitors, local business and Maps data, domain overviews, backlinks, saved keywords, projects, and rank trackers.
- Web search can find current market context, recent pages, reviews, docs, social profiles, and contact paths outside OpenSEO. - Web search can find current market context, recent pages, reviews, docs, social profiles, and contact paths outside OpenSEO.
- Browser/page scraping can extract page copy, headings, author names, contact links, schema, and content structure. - Browser/page scraping can extract page copy, headings, author names, contact links, schema, and content structure.
- Local files can preserve strategy, GSC CSVs, content briefs, crawls, prospect lists, and prior decisions over time. - Local files can preserve strategy, GSC CSVs, content briefs, crawls, prospect lists, and prior decisions over time.
@ -78,6 +78,7 @@ When the user asks for strategy:
- Separate SEO competitors from business competitors. - Separate SEO competitors from business competitors.
- Prioritize pages and topics that can plausibly create business value. - Prioritize pages and topics that can plausibly create business value.
- Use SERPs to understand intent instead of guessing. - Use SERPs to understand intent instead of guessing.
- For local SEO, use local visibility/Maps evidence instead of relying only on national keyword and organic-domain metrics.
When the user asks for execution: When the user asks for execution:

View File

@ -288,6 +288,8 @@ That means you can try OpenSEO for free with the starter credit, then decide if/
### Pricing sources ### Pricing sources
- DataForSEO SERP API pricing: https://dataforseo.com/apis/serp-api/pricing - DataForSEO SERP API pricing: https://dataforseo.com/apis/serp-api/pricing
- DataForSEO Business Data API pricing: https://dataforseo.com/apis/business-data-api/pricing
- DataForSEO Keywords Data API pricing: https://dataforseo.com/apis/keywords-data-api/pricing
- DataForSEO Labs pricing: https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api - DataForSEO Labs pricing: https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api
- DataForSEO Backlinks pricing: https://dataforseo.com/pricing/backlinks/backlinks - DataForSEO Backlinks pricing: https://dataforseo.com/pricing/backlinks/backlinks
- DataForSEO Lighthouse API docs: https://docs.dataforseo.com/v3/on_page/lighthouse/overview/ - DataForSEO Lighthouse API docs: https://docs.dataforseo.com/v3/on_page/lighthouse/overview/

View File

@ -18,16 +18,16 @@ const toolCategories: ToolCategory[] = [
title: "Research keywords", title: "Research keywords",
description: "Get keyword ideas with volume, difficulty, and CPC.", description: "Get keyword ideas with volume, difficulty, and CPC.",
}, },
{
name: "get_serp_results",
title: "Get SERP results",
description: "See live Google results for a keyword.",
},
{ {
name: "get_rank_tracker", name: "get_rank_tracker",
title: "Get rank tracking positions", title: "Get rank tracking positions",
description: "Read tracked keyword positions.", description: "Read tracked keyword positions.",
}, },
{
name: "get_keyword_search_volume",
title: "Get keyword search volume",
description: "Check Google Ads volume and CPC for keyword demand.",
},
{ {
name: "list_saved_keywords", name: "list_saved_keywords",
title: "Get saved keywords", title: "Get saved keywords",
@ -41,8 +41,23 @@ const toolCategories: ToolCategory[] = [
], ],
}, },
{ {
label: "Domain", label: "Competitive Research",
tools: [ tools: [
{
name: "get_serp_results",
title: "Get SERP results",
description: "See live Google results for a keyword.",
},
{
name: "find_serp_competitors",
title: "Find SERP competitors",
description: "Compare domains across a keyword set.",
},
{
name: "get_ranked_keywords",
title: "Get ranked keywords",
description: "Find exact keyword, page, and rank rows.",
},
{ {
name: "get_domain_overview", name: "get_domain_overview",
title: "Get domain overview", title: "Get domain overview",
@ -60,6 +75,26 @@ const toolCategories: ToolCategory[] = [
}, },
], ],
}, },
{
label: "Local Business",
tools: [
{
name: "search_local_businesses",
title: "Search local businesses",
description: "Find local business candidates near a coordinate.",
},
{
name: "get_local_serp_results",
title: "Get local SERP results",
description: "Fetch one Maps or Local Finder result set.",
},
{
name: "get_google_business_questions",
title: "Get business questions",
description: "Read Google Business Profile Q&A rows.",
},
],
},
]; ];
export function AvailableTools() { export function AvailableTools() {

View File

@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("cloudflare:workers", () => ({
env: {
DATAFORSEO_API_KEY: "encoded-key",
},
}));
describe("DataForSEO raw wrappers", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
});
it("uses the live endpoint for Google Business Q&A", async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
Response.json({
status_code: 20000,
tasks: [
{
status_code: 20000,
path: [
"v3",
"business_data",
"google",
"questions_and_answers",
"live",
],
cost: 0.0006,
result_count: 1,
result: [
{
items: [
{
question_text: "Do you offer indoor storage?",
answer_text: "Yes.",
},
],
},
],
},
],
}),
);
vi.stubGlobal("fetch", fetchMock);
const { fetchBusinessQuestionsAnswersRaw } = await import("./dataforseo");
const result = await fetchBusinessQuestionsAnswersRaw({
keyword: "Acme Storage",
locationCoordinate: "33.1234568,-84.9876543,5000",
languageCode: "en",
depth: 20,
});
expect(
fetchMock.mock.calls.map(([url]) =>
typeof url === "string" || url instanceof URL
? url.toString()
: url.url,
),
).toEqual([
"https://api.dataforseo.com/v3/business_data/google/questions_and_answers/live",
]);
expect(result.data).toEqual([
{
question_text: "Do you offer indoor storage?",
answer_text: "Yes.",
},
]);
expect(result.billing).toEqual({
path: ["v3", "business_data", "google", "questions_and_answers", "live"],
costUsd: 0.0006,
resultCount: 1,
});
});
it("does not send location_name for keyword search volume", async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
Response.json({
status_code: 20000,
tasks: [
{
status_code: 20000,
path: [
"v3",
"keywords_data",
"google_ads",
"search_volume",
"live",
],
cost: 0.0001,
result_count: 1,
result: [
{
items: [
{
keyword: "storage units",
location_code: 2840,
language_code: "en",
search_volume: 1000,
},
],
},
],
},
],
}),
);
vi.stubGlobal("fetch", fetchMock);
const { fetchKeywordSearchVolumeRaw } = await import("./dataforseo");
await fetchKeywordSearchVolumeRaw({
keywords: ["storage units"],
locationCode: 2840,
languageCode: "en",
});
const init = fetchMock.mock.calls[0]?.[1];
expect(typeof init?.body).toBe("string");
const body = init?.body;
if (typeof body !== "string") {
throw new Error("Expected DataForSEO request body to be a string");
}
const payload = JSON.parse(body) as unknown;
expect(payload).toEqual([
{
keywords: ["storage units"],
location_code: 2840,
language_code: "en",
},
]);
expect(JSON.stringify(payload)).not.toContain("location_name");
});
});

View File

@ -10,7 +10,11 @@ import {
} from "dataforseo-client"; } from "dataforseo-client";
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import { z } from "zod"; import { z } from "zod";
import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost"; import {
DataforseoChargedTaskError,
type DataforseoApiCallCost,
type DataforseoApiResponse,
} from "@/server/lib/dataforseoCost";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { import {
dataforseoResponseSchema, dataforseoResponseSchema,
@ -135,6 +139,29 @@ type DataforseoTaskLike = {
result?: DataforseoTask["result"]; result?: DataforseoTask["result"];
}; };
const dataforseoGenericItemSchema = z.record(z.string(), z.unknown());
const dataforseoGenericResultSchema = z
.object({
total_count: z.number().nullable().optional(),
count: z.number().nullable().optional(),
offset: z.number().nullable().optional(),
items: z.array(dataforseoGenericItemSchema).nullable().optional(),
items_without_answers: z
.array(dataforseoGenericItemSchema)
.nullable()
.optional(),
})
.passthrough();
type GenericDataforseoItem = z.infer<typeof dataforseoGenericItemSchema>;
const dataforseoChargedTaskSchema = z.object({
path: z.array(z.string()),
cost: z.number(),
result_count: z.number().nullable().optional(),
});
function formatDataforseoErrorPayload(value: unknown): string { function formatDataforseoErrorPayload(value: unknown): string {
const text = const text =
typeof value === "string" typeof value === "string"
@ -152,6 +179,43 @@ function formatDataforseoErrorPayload(value: unknown): string {
: text; : text;
} }
function compactObject(
value: Record<string, unknown>,
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(value).filter(([, entry]) => entry !== undefined),
);
}
function extractGenericItems(task: DataforseoTask): GenericDataforseoItem[] {
const result = task.result ?? [];
const resultItems = z.array(dataforseoGenericResultSchema).parse(result);
const resultHasNestedItems = resultItems.some((item) =>
Object.hasOwn(item, "items"),
);
return resultHasNestedItems
? resultItems.flatMap((item) => [
...(item.items ?? []),
...(item.items_without_answers ?? []),
])
: z.array(dataforseoGenericItemSchema).parse(result);
}
async function postGenericItems(
path: string,
payload: Record<string, unknown>,
): Promise<DataforseoApiResponse<GenericDataforseoItem[]>> {
const responseRaw = await postDataforseo(path, [compactObject(payload)]);
const response = dataforseoResponseSchema.parse(responseRaw);
const task = assertOk(response);
return {
data: extractGenericItems(task),
billing: buildTaskBilling(task),
};
}
function getTaskDebugPayload(task: DataforseoTaskLike) { function getTaskDebugPayload(task: DataforseoTaskLike) {
return { return {
id: task.id ?? null, id: task.id ?? null,
@ -192,6 +256,13 @@ function assertOk<T extends DataforseoTaskLike>(
throw new AppError("INTERNAL_ERROR", "DataForSEO response missing task"); throw new AppError("INTERNAL_ERROR", "DataForSEO response missing task");
} }
if (task.status_code !== 20000) { if (task.status_code !== 20000) {
const chargedTask = dataforseoChargedTaskSchema.safeParse(task);
if (chargedTask.success) {
throw new DataforseoChargedTaskError(
task.status_message || "DataForSEO task failed",
buildTaskBilling(chargedTask.data),
);
}
throw new AppError( throw new AppError(
"INTERNAL_ERROR", "INTERNAL_ERROR",
task.status_message || "DataForSEO task failed", task.status_message || "DataForSEO task failed",
@ -222,11 +293,15 @@ function assertOk<T extends DataforseoTaskLike>(
return parsedTask.data; return parsedTask.data;
} }
function buildTaskBilling(task: DataforseoTask) { function buildTaskBilling(task: {
path: string[];
cost: number;
result_count?: number | null;
}): DataforseoApiCallCost {
return { return {
path: task.path, path: task.path,
costUsd: task.cost, costUsd: task.cost,
resultCount: task.result_count, resultCount: task.result_count ?? null,
}; };
} }
@ -363,6 +438,8 @@ export async function fetchRankedKeywordsRaw(input: {
offset?: number; offset?: number;
orderBy?: string[]; orderBy?: string[];
filters?: unknown[]; filters?: unknown[];
itemTypes?: DataforseoLabsItemType[];
includeSubdomains?: boolean;
}): Promise<DataforseoApiResponse<RankedKeywordsPage>> { }): Promise<DataforseoApiResponse<RankedKeywordsPage>> {
const api = getLabsApi(); const api = getLabsApi();
const req = new DataforseoLabsGoogleRankedKeywordsLiveRequestInfo({ const req = new DataforseoLabsGoogleRankedKeywordsLiveRequestInfo({
@ -373,6 +450,8 @@ export async function fetchRankedKeywordsRaw(input: {
offset: input.offset, offset: input.offset,
order_by: input.orderBy, order_by: input.orderBy,
filters: input.filters, filters: input.filters,
item_types: input.itemTypes,
include_subdomains: input.includeSubdomains,
}); });
const endpoint = "google-ranked-keywords-live"; const endpoint = "google-ranked-keywords-live";
@ -461,6 +540,30 @@ export async function fetchLiveSerpItemsRaw(
}; };
} }
export async function fetchLocalSerpItemsRaw(input: {
keyword: string;
locationCoordinate?: string;
languageCode: string;
searchType: "maps" | "local_finder";
device: "desktop" | "mobile";
depth: number;
searchPlaces?: boolean;
}): Promise<DataforseoApiResponse<GenericDataforseoItem[]>> {
const path =
input.searchType === "maps"
? "/v3/serp/google/maps/live/advanced"
: "/v3/serp/google/local_finder/live/advanced";
return postGenericItems(path, {
keyword: input.keyword,
location_coordinate: input.locationCoordinate,
language_code: input.languageCode,
device: input.device,
os: input.device === "desktop" ? "windows" : "android",
depth: input.depth,
search_places: input.searchPlaces,
});
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SERP Rank Check API wrapper (Google Organic Live with target matching) // SERP Rank Check API wrapper (Google Organic Live with target matching)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -621,3 +724,75 @@ export async function fetchKeywordOverviewRaw(
billing: buildTaskBilling(parsedTask.data), billing: buildTaskBilling(parsedTask.data),
}; };
} }
export async function fetchBusinessListingsSearchRaw(input: {
categories?: string[];
title?: string;
locationCoordinate: string;
orderBy?: string[];
limit: number;
}): Promise<DataforseoApiResponse<GenericDataforseoItem[]>> {
return postGenericItems("/v3/business_data/business_listings/search/live", {
categories: input.categories,
title: input.title,
location_coordinate: input.locationCoordinate,
order_by: input.orderBy,
limit: input.limit,
});
}
export async function fetchBusinessQuestionsAnswersRaw(input: {
keyword: string;
locationCoordinate: string;
languageCode: string;
depth: number;
}): Promise<DataforseoApiResponse<GenericDataforseoItem[]>> {
return postGenericItems(
"/v3/business_data/google/questions_and_answers/live",
{
keyword: input.keyword,
location_coordinate: input.locationCoordinate,
language_code: input.languageCode,
depth: input.depth,
},
);
}
export async function fetchKeywordSearchVolumeRaw(input: {
keywords: string[];
locationCode?: number;
languageCode?: string;
}): Promise<DataforseoApiResponse<GenericDataforseoItem[]>> {
return postGenericItems("/v3/keywords_data/google_ads/search_volume/live", {
keywords: input.keywords,
location_code: input.locationCode,
language_code: input.languageCode,
});
}
export type DataforseoLabsItemType =
| "organic"
| "paid"
| "featured_snippet"
| "local_pack"
| "ai_overview_reference";
export async function fetchSerpCompetitorsRaw(input: {
keywords: string[];
locationCode: number;
languageCode: string;
itemTypes?: DataforseoLabsItemType[];
includeSubdomains?: boolean;
limit: number;
offset?: number;
}): Promise<DataforseoApiResponse<GenericDataforseoItem[]>> {
return postGenericItems("/v3/dataforseo_labs/google/serp_competitors/live", {
keywords: input.keywords,
location_code: input.locationCode,
language_code: input.languageCode,
item_types: input.itemTypes,
include_subdomains: input.includeSubdomains,
limit: input.limit,
offset: input.offset,
});
}

View File

@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
@ -48,7 +49,12 @@ vi.mock("@/server/lib/dataforseo", () => ({
fetchKeywordIdeasRaw: vi.fn(), fetchKeywordIdeasRaw: vi.fn(),
fetchKeywordSuggestionsRaw: vi.fn(), fetchKeywordSuggestionsRaw: vi.fn(),
fetchRelatedKeywordsRaw: vi.fn(), fetchRelatedKeywordsRaw: vi.fn(),
fetchBusinessListingsSearchRaw: vi.fn(),
fetchBusinessQuestionsAnswersRaw: vi.fn(),
fetchDomainRankOverviewRaw: vi.fn(), fetchDomainRankOverviewRaw: vi.fn(),
fetchKeywordSearchVolumeRaw: vi.fn(),
fetchLocalSerpItemsRaw: vi.fn(),
fetchSerpCompetitorsRaw: vi.fn(),
fetchRankedKeywordsRaw: vi.fn(), fetchRankedKeywordsRaw: vi.fn(),
fetchLiveSerpItemsRaw: vi.fn(), fetchLiveSerpItemsRaw: vi.fn(),
})); }));
@ -76,6 +82,7 @@ import {
createDataforseoClient, createDataforseoClient,
mapDataforseoPathToCreditFeature, mapDataforseoPathToCreditFeature,
} from "./dataforseoClient"; } from "./dataforseoClient";
import { DataforseoChargedTaskError } from "./dataforseoCost";
import { fetchBacklinksSummaryRaw } from "./dataforseoBacklinks"; import { fetchBacklinksSummaryRaw } from "./dataforseoBacklinks";
const billingCustomer = { const billingCustomer = {
@ -228,6 +235,32 @@ describe("meterDataforseoCall with split balances", () => {
expect(trackMock).not.toHaveBeenCalled(); expect(trackMock).not.toHaveBeenCalled();
}); });
it("meters charged DataForSEO task errors before rethrowing", async () => {
setupHostedMode();
mockBalances(5000, 3000);
vi.mocked(fetchBacklinksSummaryRaw).mockRejectedValue(
new DataforseoChargedTaskError("DataForSEO task failed", {
costUsd: RAW_COST,
path: ["v3", "backlinks", "summary", "live"],
resultCount: 0,
}),
);
const client = createDataforseoClient(billingCustomer);
await expect(client.backlinks.summary(backlinksInput)).rejects.toThrow(
"DataForSEO task failed",
);
expect(trackMock).toHaveBeenCalledTimes(1);
expect(trackMock).toHaveBeenCalledWith(
expect.objectContaining({
customerId: "org_123",
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
value: EXPECTED_CREDITS,
}),
);
});
it("includes balanceFeatureId in track properties", async () => { it("includes balanceFeatureId in track properties", async () => {
setupHostedMode(); setupHostedMode();
mockBalances(30, 5000); mockBalances(30, 5000);
@ -372,4 +405,45 @@ describe("mapDataforseoPathToCreditFeature", () => {
]), ]),
).toBe("ai_search"); ).toBe("ai_search");
}); });
it("maps local and supporting paths to the intended credit features", () => {
expect(
mapDataforseoPathToCreditFeature([
"v3",
"business_data",
"business_listings",
"search",
"live",
]),
).toBe("local_seo");
expect(
mapDataforseoPathToCreditFeature([
"v3",
"serp",
"google",
"local_finder",
"live",
"advanced",
]),
).toBe("local_seo");
expect(
mapDataforseoPathToCreditFeature([
"v3",
"serp",
"google",
"maps",
"live",
"advanced",
]),
).toBe("local_seo");
expect(
mapDataforseoPathToCreditFeature([
"v3",
"keywords_data",
"google_ads",
"search_volume",
"live",
]),
).toBe("keyword_research");
});
}); });

View File

@ -1,3 +1,4 @@
/* eslint-disable max-lines, max-lines-per-function */
import { import {
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_CREDITS_PER_USD, AUTUMN_SEO_DATA_CREDITS_PER_USD,
@ -13,9 +14,15 @@ import {
fetchKeywordOverviewRaw, fetchKeywordOverviewRaw,
fetchKeywordSuggestionsRaw, fetchKeywordSuggestionsRaw,
fetchRelatedKeywordsRaw, fetchRelatedKeywordsRaw,
fetchBusinessListingsSearchRaw,
fetchBusinessQuestionsAnswersRaw,
fetchDomainRankOverviewRaw, fetchDomainRankOverviewRaw,
fetchKeywordSearchVolumeRaw,
fetchLocalSerpItemsRaw,
fetchRankedKeywordsRaw, fetchRankedKeywordsRaw,
fetchRelevantPagesRaw, fetchRelevantPagesRaw,
fetchSerpCompetitorsRaw,
type DataforseoLabsItemType,
fetchLiveSerpItemsRaw, fetchLiveSerpItemsRaw,
fetchRankCheckSerpRaw, fetchRankCheckSerpRaw,
type LabsKeywordDataItem, type LabsKeywordDataItem,
@ -47,6 +54,7 @@ import {
import { import {
type DataforseoApiResponse, type DataforseoApiResponse,
type DataforseoApiCallCost, type DataforseoApiCallCost,
DataforseoChargedTaskError,
} from "@/server/lib/dataforseoCost"; } from "@/server/lib/dataforseoCost";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { captureServerEvent } from "@/server/lib/posthog"; import { captureServerEvent } from "@/server/lib/posthog";
@ -58,7 +66,8 @@ type CreditFeature =
| "backlinks" | "backlinks"
| "site_audit" | "site_audit"
| "rank_tracking" | "rank_tracking"
| "ai_search"; | "ai_search"
| "local_seo";
/** /**
* Maps a DataForSEO API response path (e.g. ["v3", "dataforseo_labs", "google", "related_keywords", "live"]) * Maps a DataForSEO API response path (e.g. ["v3", "dataforseo_labs", "google", "related_keywords", "live"])
@ -76,9 +85,15 @@ export function mapDataforseoPathToCreditFeature(
case "backlinks": case "backlinks":
return "backlinks"; return "backlinks";
case "serp": case "serp":
return "keyword_research"; return path[2] === "google" && ["maps", "local_finder"].includes(path[3])
? "local_seo"
: "keyword_research";
case "ai_optimization": case "ai_optimization":
return "ai_search"; return "ai_search";
case "business_data":
return "local_seo";
case "keywords_data":
return "keyword_research";
case "dataforseo_labs": { case "dataforseo_labs": {
const endpoint = path[3] ?? ""; const endpoint = path[3] ?? "";
if ( if (
@ -97,6 +112,33 @@ export function mapDataforseoPathToCreditFeature(
export function createDataforseoClient(customer: BillingCustomerContext) { export function createDataforseoClient(customer: BillingCustomerContext) {
return { return {
business: {
businessListings(input: {
categories?: string[];
title?: string;
locationCoordinate: string;
orderBy?: string[];
limit: number;
}) {
return meterDataforseoCall(
customer,
() => fetchBusinessListingsSearchRaw(input),
"local_seo",
);
},
questionsAnswers(input: {
keyword: string;
locationCoordinate: string;
languageCode: string;
depth: number;
}) {
return meterDataforseoCall(
customer,
() => fetchBusinessQuestionsAnswersRaw(input),
"local_seo",
);
},
},
backlinks: { backlinks: {
summary(input: BacklinksRequest) { summary(input: BacklinksRequest) {
return meterDataforseoCall(customer, () => return meterDataforseoCall(customer, () =>
@ -195,6 +237,8 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
offset?: number; offset?: number;
orderBy?: string[]; orderBy?: string[];
filters?: unknown[]; filters?: unknown[];
itemTypes?: DataforseoLabsItemType[];
includeSubdomains?: boolean;
}) { }) {
return meterDataforseoCall(customer, () => return meterDataforseoCall(customer, () =>
fetchRankedKeywordsRaw(input), fetchRankedKeywordsRaw(input),
@ -214,24 +258,6 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
); );
}, },
}, },
labs: {
keywordOverview(input: {
keywords: string[];
locationCode: number;
languageCode: string;
}) {
return meterDataforseoCall(
customer,
() =>
fetchKeywordOverviewRaw(
input.keywords,
input.locationCode,
input.languageCode,
),
"rank_tracking",
);
},
},
serp: { serp: {
live(input: { live(input: {
keyword: string; keyword: string;
@ -261,6 +287,63 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
"rank_tracking", "rank_tracking",
); );
}, },
local(input: {
keyword: string;
locationCoordinate?: string;
languageCode: string;
searchType: "maps" | "local_finder";
device: "desktop" | "mobile";
depth: number;
searchPlaces?: boolean;
}) {
return meterDataforseoCall(
customer,
() => fetchLocalSerpItemsRaw(input),
"local_seo",
);
},
},
keywordData: {
searchVolume(input: {
keywords: string[];
locationCode?: number;
languageCode?: string;
}) {
return meterDataforseoCall(customer, () =>
fetchKeywordSearchVolumeRaw(input),
);
},
},
labs: {
keywordOverview(input: {
keywords: string[];
locationCode: number;
languageCode: string;
}) {
return meterDataforseoCall(
customer,
() =>
fetchKeywordOverviewRaw(
input.keywords,
input.locationCode,
input.languageCode,
),
"rank_tracking",
);
},
serpCompetitors(input: {
keywords: string[];
locationCode: number;
languageCode: string;
itemTypes?: DataforseoLabsItemType[];
includeSubdomains?: boolean;
limit: number;
offset?: number;
}) {
return meterDataforseoCall(customer, () =>
fetchSerpCompetitorsRaw(input),
);
},
}, },
lighthouse: { lighthouse: {
live(input: { url: string; strategy: LighthouseStrategy }) { live(input: { url: string; strategy: LighthouseStrategy }) {
@ -308,7 +391,21 @@ async function meterDataforseoCall<T>(
billingCustomer.id, billingCustomer.id,
); );
const result = await execute(); let result: DataforseoApiResponse<T>;
try {
result = await execute();
} catch (error) {
if (error instanceof DataforseoChargedTaskError) {
await trackDataforseoCost({
customer,
customerId: billingCustomer.id,
billing: error.billing,
monthlyRemaining,
creditFeature,
});
}
throw error;
}
await trackDataforseoCost({ await trackDataforseoCost({
customer, customer,

View File

@ -1,3 +1,5 @@
import { AppError } from "@/server/lib/errors";
export type DataforseoApiCallCost = { export type DataforseoApiCallCost = {
path: string[]; path: string[];
costUsd: number; costUsd: number;
@ -8,3 +10,13 @@ export type DataforseoApiResponse<T> = {
data: T; data: T;
billing: DataforseoApiCallCost; billing: DataforseoApiCallCost;
}; };
export class DataforseoChargedTaskError extends AppError {
constructor(
message: string,
public readonly billing: DataforseoApiCallCost,
) {
super("INTERNAL_ERROR", message);
this.name = "DataforseoChargedTaskError";
}
}

View File

@ -6,6 +6,14 @@ import { getRankTrackerTool } from "@/server/mcp/tools/get-rank-tracker";
import { getSerpResultsTool } from "@/server/mcp/tools/get-serp-results"; import { getSerpResultsTool } from "@/server/mcp/tools/get-serp-results";
import { listProjectsTool } from "@/server/mcp/tools/list-projects"; import { listProjectsTool } from "@/server/mcp/tools/list-projects";
import { listSavedKeywordsTool } from "@/server/mcp/tools/list-saved-keywords"; import { listSavedKeywordsTool } from "@/server/mcp/tools/list-saved-keywords";
import {
findSerpCompetitorsTool,
getGoogleBusinessQuestionsTool,
getKeywordSearchVolumeTool,
getLocalSerpResultsTool,
getRankedKeywordsTool,
searchLocalBusinessesTool,
} from "@/server/mcp/tools/dataforseo-research-tools";
import { researchKeywordsTool } from "@/server/mcp/tools/research-keywords"; import { researchKeywordsTool } from "@/server/mcp/tools/research-keywords";
import { saveKeywordsTool } from "@/server/mcp/tools/save-keywords"; import { saveKeywordsTool } from "@/server/mcp/tools/save-keywords";
import { whoamiTool } from "@/server/mcp/tools/whoami"; import { whoamiTool } from "@/server/mcp/tools/whoami";
@ -57,4 +65,34 @@ export function registerOpenSeoMcpTools(server: McpServer) {
getRankTrackerTool.config, getRankTrackerTool.config,
getRankTrackerTool.handler, getRankTrackerTool.handler,
); );
server.registerTool(
getRankedKeywordsTool.name,
getRankedKeywordsTool.config,
getRankedKeywordsTool.handler,
);
server.registerTool(
findSerpCompetitorsTool.name,
findSerpCompetitorsTool.config,
findSerpCompetitorsTool.handler,
);
server.registerTool(
searchLocalBusinessesTool.name,
searchLocalBusinessesTool.config,
searchLocalBusinessesTool.handler,
);
server.registerTool(
getLocalSerpResultsTool.name,
getLocalSerpResultsTool.config,
getLocalSerpResultsTool.handler,
);
server.registerTool(
getGoogleBusinessQuestionsTool.name,
getGoogleBusinessQuestionsTool.config,
getGoogleBusinessQuestionsTool.handler,
);
server.registerTool(
getKeywordSearchVolumeTool.name,
getKeywordSearchVolumeTool.config,
getKeywordSearchVolumeTool.handler,
);
} }

View File

@ -0,0 +1,343 @@
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import type { ToolExtra } from "@/server/mcp/context";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
const mocks = vi.hoisted(() => ({
createDataforseoClient: vi.fn(),
getProjectForOrganization: vi.fn(),
}));
vi.mock("cloudflare:workers", () => ({
env: {},
}));
vi.mock("@/server/lib/dataforseoClient", () => ({
createDataforseoClient: mocks.createDataforseoClient,
}));
vi.mock("@/server/features/projects/services/ProjectService", () => ({
ProjectService: {
getProjectForOrganization: mocks.getProjectForOrganization,
},
}));
const authContext = {
userId: "user_123",
userEmail: "alice@example.com",
organizationId: "org_123",
clientId: "client_123",
scopes: ["mcp"],
audience: "https://open-seo.test/mcp",
subject: "user_123",
baseUrl: "https://open-seo.test",
};
const toolExtra: ToolExtra = {
signal: new AbortController().signal,
requestId: 1,
sendNotification: vi.fn(),
sendRequest: vi.fn(),
authInfo: {
token: "token",
clientId: "client_123",
scopes: ["mcp"],
resource: new URL("https://open-seo.test/mcp"),
extra: { [MCP_AUTH_CONTEXT_PROP]: authContext },
} satisfies AuthInfo,
};
describe("DataForSEO research MCP tools", () => {
beforeEach(() => {
vi.resetModules();
mocks.createDataforseoClient.mockReset();
mocks.getProjectForOrganization.mockReset();
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" });
});
it("searches local businesses without running rankings or Q&A", async () => {
const businessListings = vi
.fn()
.mockResolvedValue([
{ title: "Acme Cafe", url: "https://acme-cafe.example" },
]);
const local = vi.fn();
const questionsAnswers = vi.fn();
mocks.createDataforseoClient.mockReturnValue({
business: { businessListings, questionsAnswers },
serp: { local },
});
const { searchLocalBusinessesTool } =
await import("./dataforseo-research-tools");
const result = await searchLocalBusinessesTool.handler(
{
projectId: "project_1",
query: "Acme Cafe",
near: {
latitude: 33.123456789,
longitude: -84.987654321,
radiusKm: 5,
},
categories: ["cafe"],
},
toolExtra,
);
expect(businessListings).toHaveBeenCalledWith(
expect.objectContaining({
locationCoordinate: "33.1234568,-84.9876543,5",
categories: ["cafe"],
}),
);
expect(local).not.toHaveBeenCalled();
expect(questionsAnswers).not.toHaveBeenCalled();
const content = z
.object({ businesses: z.array(z.object({ title: z.string() })) })
.passthrough()
.parse(result.structuredContent);
expect(content.businesses).toEqual([{ title: "Acme Cafe" }]);
});
it("fetches one local SERP with search_places disabled", async () => {
const local = vi.fn().mockResolvedValue([
{
type: "maps_search",
title: "Acme Cafe",
rank_group: 1,
rank_absolute: 2,
},
]);
mocks.createDataforseoClient.mockReturnValue({
serp: { local },
});
const { getLocalSerpResultsTool } =
await import("./dataforseo-research-tools");
const result = await getLocalSerpResultsTool.handler(
{
projectId: "project_1",
keyword: "coffee",
near: {
latitude: 33.123456789,
longitude: -84.987654321,
zoom: 14,
},
},
toolExtra,
);
expect(local).toHaveBeenCalledWith(
expect.objectContaining({
locationCoordinate: "33.1234568,-84.9876543,14z",
searchPlaces: false,
searchType: "maps",
device: "desktop",
}),
);
const content = z
.object({
results: z.array(
z.object({ rank_group: z.number(), rank_absolute: z.number() }),
),
})
.passthrough()
.parse(result.structuredContent);
expect(content.results[0]).toMatchObject({
rank_group: 1,
rank_absolute: 2,
});
});
it("fetches Google Business Q&A as an explicit tool", async () => {
const questionsAnswers = vi
.fn()
.mockResolvedValue([{ question_text: "Do you serve breakfast?" }]);
mocks.createDataforseoClient.mockReturnValue({
business: { questionsAnswers },
});
const { getGoogleBusinessQuestionsTool } =
await import("./dataforseo-research-tools");
const result = await getGoogleBusinessQuestionsTool.handler(
{
projectId: "project_1",
keyword: "Acme Cafe",
near: {
latitude: 33.123456789,
longitude: -84.987654321,
radiusKm: 5,
},
},
toolExtra,
);
expect(questionsAnswers).toHaveBeenCalledWith(
expect.objectContaining({
keyword: "Acme Cafe",
locationCoordinate: "33.1234568,-84.9876543,5000",
}),
);
const content = z
.object({ questions: z.array(z.object({ question_text: z.string() })) })
.passthrough()
.parse(result.structuredContent);
expect(content.questions).toEqual([
{ question_text: "Do you serve breakfast?" },
]);
});
it("passes only explicit brand exclusions to ranked keyword filters", async () => {
const rankedKeywords = vi.fn().mockResolvedValue({
items: [],
totalCount: 0,
});
mocks.createDataforseoClient.mockReturnValue({
domain: { rankedKeywords },
});
const { getRankedKeywordsTool } =
await import("./dataforseo-research-tools");
await getRankedKeywordsTool.handler(
{
projectId: "project_1",
target: "acmeexample.com",
excludeBrandTerms: ["acme"],
},
toolExtra,
);
expect(rankedKeywords).toHaveBeenCalledWith(
expect.objectContaining({
filters: [["keyword_data.keyword", "not_ilike", "%acme%"]],
}),
);
});
it("filters SERP competitors only by explicit excluded domains", async () => {
const serpCompetitors = vi.fn().mockResolvedValue([
{ domain: "directory.example", visibility: 10 },
{ domain: "competitor.example", visibility: 5 },
]);
mocks.createDataforseoClient.mockReturnValue({
labs: { serpCompetitors },
});
const { findSerpCompetitorsTool } =
await import("./dataforseo-research-tools");
const result = await findSerpCompetitorsTool.handler(
{
projectId: "project_1",
keywords: ["coffee"],
excludeDomains: ["directory.example"],
},
toolExtra,
);
const content = z
.object({ competitors: z.array(z.object({ domain: z.string() })) })
.passthrough()
.parse(result.structuredContent);
expect(content.competitors.map((row) => row.domain)).toEqual([
"competitor.example",
]);
});
it("keeps AI overview result types out of SERP competitors", async () => {
const { findSerpCompetitorsTool, getRankedKeywordsTool } =
await import("./dataforseo-research-tools");
expect(
getRankedKeywordsTool.config.inputSchema.resultTypes.safeParse([
"ai_overview_reference",
]).success,
).toBe(true);
expect(
findSerpCompetitorsTool.config.inputSchema.resultTypes.safeParse([
"ai_overview_reference",
]).success,
).toBe(false);
expect(
findSerpCompetitorsTool.config.inputSchema.resultTypes.safeParse([
"organic",
"local_pack",
]).success,
).toBe(true);
});
it("sorts keyword volume rows by numeric competition index", async () => {
const searchVolume = vi.fn().mockResolvedValue([
{ keyword: "low", competition: "LOW", competition_index: 10 },
{ keyword: "high", competition: "HIGH", competition_index: 90 },
{ keyword: "medium", competition: "MEDIUM", competition_index: 50 },
]);
mocks.createDataforseoClient.mockReturnValue({
keywordData: { searchVolume },
});
const { getKeywordSearchVolumeTool } =
await import("./dataforseo-research-tools");
const result = await getKeywordSearchVolumeTool.handler(
{
projectId: "project_1",
keywords: ["low", "high", "medium"],
sortBy: "competition",
},
toolExtra,
);
const rows = z
.object({ keywords: z.array(z.object({ keyword: z.string() })) })
.passthrough()
.parse(result.structuredContent).keywords;
expect(rows.map((row) => row.keyword)).toEqual(["high", "medium", "low"]);
});
it("defaults empty keyword volume market objects to United States", async () => {
const searchVolume = vi
.fn()
.mockResolvedValue([{ keyword: "storage units", search_volume: 1000 }]);
mocks.createDataforseoClient.mockReturnValue({
keywordData: { searchVolume },
});
const { getKeywordSearchVolumeTool } =
await import("./dataforseo-research-tools");
await getKeywordSearchVolumeTool.handler(
{
projectId: "project_1",
keywords: ["storage units"],
market: {},
},
toolExtra,
);
expect(searchVolume).toHaveBeenCalledWith(
expect.objectContaining({
locationCode: 2840,
}),
);
});
it("does not accept keyword volume location names", async () => {
const { getKeywordSearchVolumeTool } =
await import("./dataforseo-research-tools");
expect(
getKeywordSearchVolumeTool.config.inputSchema.market?.safeParse({
country: "US",
locationName: "Pittsburgh,PA,United States",
}).success,
).toBe(false);
});
});

View File

@ -0,0 +1,611 @@
/* eslint-disable max-lines */
import { z } from "zod";
import { AppError } from "@/server/lib/errors";
import { createDataforseoClient } from "@/server/lib/dataforseoClient";
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,
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(),
})
.optional()
.describe("Optional United States market object. Defaults to United States.");
const nearSchema = z.object({
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
radiusKm: z.number().min(1).max(100000),
});
const localSerpNearSchema = z.object({
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
zoom: z.number().int().min(4).max(18).optional(),
});
const keywordMarketSchema = z
.object({
country: z
.enum(["US", "USA", "United States", "United States of America"])
.optional(),
})
.strict()
.optional()
.describe("Optional Google Ads market. Defaults to United States.");
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 looseRecordSchema = z.record(z.string(), z.unknown());
const getRankedKeywordsInputSchema = {
projectId: projectIdSchema,
target: rankedTargetSchema,
market: marketSchema,
resultTypes: z.array(rankedResultTypeSchema).min(1).max(5).optional(),
includeSubdomains: z.boolean().optional(),
minSearchVolume: z.number().int().min(0).optional(),
maxRank: z.number().int().min(1).max(100).optional(),
excludeBrandTerms: z
.array(z.string().min(1).max(80))
.min(1)
.max(10)
.optional(),
sortBy: z
.enum(["rank", "search_volume", "traffic_estimate", "cpc"])
.optional(),
limit: z.number().int().min(1).max(100).optional(),
offset: z.number().int().min(0).max(1000).optional(),
} as const;
const searchLocalBusinessesInputSchema = {
projectId: projectIdSchema,
query: z.string().min(1).max(200).optional(),
near: nearSchema,
categories: z.array(z.string().min(1).max(120)).min(1).max(10).optional(),
limit: z.number().int().min(1).max(50).optional(),
} as const;
const localSearchTypeSchema = z.enum(["maps", "local_finder"]);
const getLocalSerpResultsInputSchema = {
projectId: projectIdSchema,
keyword: z.string().min(1).max(120),
near: localSerpNearSchema,
searchType: localSearchTypeSchema.optional(),
device: z.enum(["desktop", "mobile"]).optional(),
depth: z.number().int().min(1).max(100).optional(),
languageCode: languageCodeSchema.optional(),
} as const;
const getGoogleBusinessQuestionsInputSchema = {
projectId: projectIdSchema,
keyword: z.string().min(1).max(200),
near: nearSchema,
depth: z.number().int().min(1).max(100).optional(),
languageCode: languageCodeSchema.optional(),
} as const;
const findSerpCompetitorsInputSchema = {
projectId: projectIdSchema,
keywords: z.array(z.string().min(1).max(120)).min(1).max(100),
market: marketSchema,
resultTypes: z.array(serpCompetitorResultTypeSchema).min(1).max(4).optional(),
excludeDomains: z.array(domainTargetSchema).min(1).max(50).optional(),
includeSubdomains: z.boolean().optional(),
sortBy: z
.enum(["visibility", "traffic_estimate", "avg_position", "keyword_count"])
.optional(),
limit: z.number().int().min(1).max(100).optional(),
offset: z.number().int().min(0).max(1000).optional(),
} as const;
const getKeywordSearchVolumeInputSchema = {
projectId: projectIdSchema,
keywords: z.array(z.string().min(1).max(80)).min(1).max(100),
market: keywordMarketSchema,
includeMonthlyTrends: z.boolean().optional(),
sortBy: z.enum(["search_volume", "cpc", "competition"]).optional(),
languageCode: languageCodeSchema.optional(),
} 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 GetKeywordSearchVolumeArgs = z.infer<
z.ZodObject<typeof getKeywordSearchVolumeInputSchema>
>;
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 {
const country = market?.country?.trim().toLowerCase();
if (
!country ||
["us", "usa", "united states", "united states of america"].includes(country)
) {
return DEFAULT_LOCATION_CODE;
}
throw new AppError(
"VALIDATION_ERROR",
"Only United States country targeting is supported by this MCP tool today.",
);
}
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 asRecord(value: unknown): Record<string, unknown> | undefined {
const parsed = looseRecordSchema.safeParse(value);
return parsed.success ? parsed.data : 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 sortKeywordRows(
items: Record<string, unknown>[],
sortBy: GetKeywordSearchVolumeArgs["sortBy"],
) {
const field =
sortBy === "cpc"
? "cpc"
: sortBy === "competition"
? "competition_index"
: "search_volume";
return items.toSorted((a, b) => {
const aValue = typeof a[field] === "number" ? a[field] : 0;
const bValue = typeof b[field] === "number" ? b[field] : 0;
return bValue - aValue;
});
}
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 DataForSEO Labs 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 DataForSEO 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 DataForSEO Business Data 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 DataForSEO SERP 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 DataForSEO Business Data 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 for a supplied keyword set using DataForSEO Labs SERP Competitors. Useful for market and search-intelligence reports; not radius-based local SEO. Charges DataForSEO Labs 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 getKeywordSearchVolumeTool = {
name: "get_keyword_search_volume",
config: {
title: "Get keyword search volume",
description:
"Checks Google Ads keyword planner-style search volume, CPC, competition, and monthly trends for known keywords. This is demand prioritization data, not local-radius rank data. Charges DataForSEO Keywords Data credits.",
inputSchema: getKeywordSearchVolumeInputSchema,
outputSchema: {
keywords: z.array(looseObjectOutputSchema),
...optionalMetaOutputSchema,
},
annotations: {
readOnlyHint: true,
openWorldHint: false,
destructiveHint: false,
},
},
handler: withMcpProjectAuth(
async (args: GetKeywordSearchVolumeArgs, context) => {
const client = createDataforseoClient(context.billing);
const keywords = await client.keywordData.searchVolume({
keywords: args.keywords,
locationCode: resolveMarketLocationCode(args.market),
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
});
const rows = sortKeywordRows(
keywords,
args.sortBy ?? "search_volume",
).map((item) =>
args.includeMonthlyTrends === false
? Object.fromEntries(
Object.entries(item).filter(
([key]) => key !== "monthly_searches",
),
)
: item,
);
return mcpResponse({
text: `Fetched search volume for ${rows.length} keyword rows.`,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/keywords`,
),
structuredContent: { keywords: rows },
});
},
),
};

View File

@ -3,7 +3,7 @@ title: "Set up OpenSEO MCP"
description: "Connect OpenSEO MCP to Claude, Codex, and other AI clients." description: "Connect OpenSEO MCP to Claude, Codex, and other AI clients."
--- ---
OpenSEO MCP lets compatible AI clients call OpenSEO tools for keyword research, SERP inspection, domain research, backlink overview, saved keywords, and rank tracking. OpenSEO MCP lets compatible AI clients call OpenSEO tools for keyword research, SERP inspection, local business research, competitive search intelligence, domain research, backlink overview, saved keywords, and rank tracking.
The hosted MCP server URL is: The hosted MCP server URL is:
@ -57,6 +57,10 @@ OpenSEO MCP exposes tools for SEO research workflows:
- Research keywords with volume, difficulty, and CPC. - Research keywords with volume, difficulty, and CPC.
- Fetch live Google organic SERP results for keywords. - Fetch live Google organic SERP results for keywords.
- Find exact keyword, page, rank, volume, CPC, intent, and traffic rows for a domain or page.
- Compare SERP competitors across a supplied keyword set.
- Search local businesses near a coordinate, fetch one Maps or Local Finder SERP, and read Google Business Q&A when needed.
- Check Google Ads keyword volume and CPC for demand prioritization.
- List saved keywords from an OpenSEO project. - List saved keywords from an OpenSEO project.
- Save useful keywords back to OpenSEO. - Save useful keywords back to OpenSEO.
- Read rank tracker configs and latest keyword positions. - Read rank tracker configs and latest keyword positions.