feat(mcp): accept locationCode/languageCode on Labs research tools (#113)

Co-authored-by: isatimur <isatimur@users.noreply.github.com>
This commit is contained in:
Slim Bouras 2026-07-23 16:05:43 +02:00 committed by GitHub
parent d3c62aa5a2
commit 2c153f9a20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 154 additions and 14 deletions

View File

@ -60,7 +60,13 @@ function setProject(market: { locationCode: number; languageCode: string }) {
}); });
} }
async function runRankedKeywords(args: { market?: { country: "US" } }) { type MarketArgs = {
market?: { country: "US" };
locationCode?: number;
languageCode?: string;
};
async function runRankedKeywords(args: MarketArgs) {
const rankedKeywords = vi.fn().mockResolvedValue({ const rankedKeywords = vi.fn().mockResolvedValue({
items: [], items: [],
totalCount: 0, totalCount: 0,
@ -76,6 +82,20 @@ async function runRankedKeywords(args: { market?: { country: "US" } }) {
return rankedKeywords; return rankedKeywords;
} }
async function runSerpCompetitors(args: MarketArgs) {
const serpCompetitors = vi.fn().mockResolvedValue([]);
mocks.createDataforseoClient.mockReturnValue({
labs: { serpCompetitors },
});
const { findSerpCompetitorsTool } =
await import("./dataforseo-research-tools");
await findSerpCompetitorsTool.handler(
{ projectId: "project_1", keywords: ["seo"], ...args },
toolExtra,
);
return serpCompetitors;
}
describe("market resolution for Labs tools", () => { describe("market resolution for Labs tools", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
@ -94,6 +114,85 @@ describe("market resolution for Labs tools", () => {
); );
}); });
it("exposes explicit location and language selectors on both tool schemas", async () => {
const { findSerpCompetitorsTool, getRankedKeywordsTool } =
await import("./dataforseo-research-tools");
expect(getRankedKeywordsTool.config.inputSchema.locationCode).toBeDefined();
expect(getRankedKeywordsTool.config.inputSchema.languageCode).toBeDefined();
expect(
findSerpCompetitorsTool.config.inputSchema.locationCode,
).toBeDefined();
expect(
findSerpCompetitorsTool.config.inputSchema.languageCode,
).toBeDefined();
});
it("passes an explicit non-US market to both Labs tools", async () => {
setProject({ locationCode: 2704, languageCode: "vi" });
const rankedKeywords = await runRankedKeywords({
locationCode: 2756,
languageCode: "de",
});
expect(rankedKeywords).toHaveBeenCalledWith(
expect.objectContaining({ locationCode: 2756, languageCode: "de" }),
);
const serpCompetitors = await runSerpCompetitors({
locationCode: 2756,
languageCode: "de",
});
expect(serpCompetitors).toHaveBeenCalledWith(
expect.objectContaining({ locationCode: 2756, languageCode: "de" }),
);
});
it("uses the selected location's default language when only locationCode is explicit", async () => {
setProject({ locationCode: 2704, languageCode: "vi" });
const rankedKeywords = await runRankedKeywords({ locationCode: 2276 });
expect(rankedKeywords).toHaveBeenCalledWith(
expect.objectContaining({ locationCode: 2276, languageCode: "de" }),
);
});
it("prefers an explicit locationCode over the legacy market object", async () => {
setProject({ locationCode: 2704, languageCode: "vi" });
const rankedKeywords = await runRankedKeywords({
locationCode: 2756,
languageCode: "de",
market: { country: "US" },
});
expect(rankedKeywords).toHaveBeenCalledWith(
expect.objectContaining({ locationCode: 2756, languageCode: "de" }),
);
});
it("accepts a non-default language the location serves", async () => {
setProject({ locationCode: 2704, languageCode: "vi" });
// Switzerland serves fr/de/it; de is the default.
const serpCompetitors = await runSerpCompetitors({
locationCode: 2756,
languageCode: "fr",
});
expect(serpCompetitors).toHaveBeenCalledWith(
expect.objectContaining({ locationCode: 2756, languageCode: "fr" }),
);
});
it("rejects a language the location does not serve", async () => {
setProject({ locationCode: 2704, languageCode: "vi" });
await expect(
runRankedKeywords({ locationCode: 2276, languageCode: "fr" }),
).rejects.toThrow("is not available for this location");
});
it("rejects an explicit non-Labs country before making a paid call", async () => {
await expect(
runRankedKeywords({ locationCode: 2352, languageCode: "is" }),
).rejects.toThrow("Domain analytics is not available for this country");
});
it("follows the project's default market when the market object is omitted", async () => { it("follows the project's default market when the market object is omitted", async () => {
setProject({ locationCode: 2704, languageCode: "vi" }); setProject({ locationCode: 2704, languageCode: "vi" });
const rankedKeywords = await runRankedKeywords({}); const rankedKeywords = await runRankedKeywords({});

View File

@ -18,7 +18,10 @@ import {
type McpTableColumn, type McpTableColumn,
} from "@/server/mcp/table"; } from "@/server/mcp/table";
import { resolveLabsMarket, resolveMarket } from "@/shared/keyword-locations"; import { resolveLabsMarket, resolveMarket } from "@/shared/keyword-locations";
import { assertLanguageForLocation } from "@/server/lib/market"; import {
assertLabsLocationCode,
assertLanguageForLocation,
} from "@/server/lib/market";
import { import {
DEFAULT_LOCATION_CODE, DEFAULT_LOCATION_CODE,
languageCodeSchema, languageCodeSchema,
@ -52,7 +55,7 @@ const marketSchema = z
}) })
.optional() .optional()
.describe( .describe(
"Optional market object. Omitted = the project's default market (United States unless the project overrides it).", "Legacy US selector. Prefer locationCode/languageCode for any Labs market. Explicit locationCode takes precedence; otherwise omitted = the project's default market.",
); );
const nearSchema = z const nearSchema = z
@ -126,6 +129,16 @@ const getRankedKeywordsInputSchema = {
"Domain (no protocol/www) or absolute page URL to list ranked keywords for.", "Domain (no protocol/www) or absolute page URL to list ranked keywords for.",
), ),
market: marketSchema, market: marketSchema,
locationCode: locationCodeSchema
.optional()
.describe(
"Country-level DataForSEO Labs location code. Defaults to the project's market; takes precedence over the legacy market object.",
),
languageCode: languageCodeSchema
.optional()
.describe(
"Language for locationCode. Defaults to that location's primary language when locationCode overrides the project market.",
),
resultTypes: z resultTypes: z
.array(rankedResultTypeSchema) .array(rankedResultTypeSchema)
.min(1) .min(1)
@ -256,6 +269,16 @@ const findSerpCompetitorsInputSchema = {
.max(100) .max(100)
.describe("Keywords whose SERPs are compared (1-100)."), .describe("Keywords whose SERPs are compared (1-100)."),
market: marketSchema, market: marketSchema,
locationCode: locationCodeSchema
.optional()
.describe(
"Country-level DataForSEO Labs location code. Defaults to the project's market; takes precedence over the legacy market object.",
),
languageCode: languageCodeSchema
.optional()
.describe(
"Language for locationCode. Defaults to that location's primary language when locationCode overrides the project market.",
),
resultTypes: z resultTypes: z
.array(serpCompetitorResultTypeSchema) .array(serpCompetitorResultTypeSchema)
.min(1) .min(1)
@ -349,19 +372,37 @@ const QUESTIONS_ANSWERS_MIN_RADIUS = 200;
const QUESTIONS_ANSWERS_MAX_RADIUS = 199999; const QUESTIONS_ANSWERS_MAX_RADIUS = 199999;
/** /**
* Resolves the market selector to a Labs location + language. An explicit * Resolves a Labs location + language. Explicit location/language fields win,
* country wins; omitted inherits the project's default via resolveLabsMarket, * followed by the legacy explicit-US selector; omitted fields inherit the
* which keeps these Labs-only tools off an Ads-served project market. * project's default via resolveLabsMarket, which keeps these Labs-only tools
* off an Ads-served project market. Validate before starting a paid request.
*/ */
function resolveMarketSelector( function resolveMarketSelector(
market: Market | undefined, selector: {
market?: Market;
locationCode?: number;
languageCode?: string;
},
project: { locationCode: number; languageCode: string }, project: { locationCode: number; languageCode: string },
): { locationCode: number; languageCode: string } { ): { locationCode: number; languageCode: string } {
if (market?.country != null) { let resolved: { locationCode: number; languageCode: string };
if (selector.locationCode != null || selector.languageCode != null) {
resolved = resolveLabsMarket(
{
locationCode: selector.locationCode,
languageCode: selector.languageCode,
},
project,
);
} else if (selector.market?.country != null) {
// The Zod enum already restricts explicit values to United States variants. // The Zod enum already restricts explicit values to United States variants.
return { locationCode: DEFAULT_LOCATION_CODE, languageCode: "en" }; resolved = { locationCode: DEFAULT_LOCATION_CODE, languageCode: "en" };
} else {
resolved = resolveLabsMarket({}, project);
} }
return resolveLabsMarket({}, project); assertLabsLocationCode(resolved.locationCode);
assertLanguageForLocation(resolved.locationCode, resolved.languageCode);
return resolved;
} }
function formatCoordinate(value: number): string { function formatCoordinate(value: number): string {
@ -592,7 +633,7 @@ export const getRankedKeywordsTool = {
config: { config: {
title: "Get ranked keywords", title: "Get ranked keywords",
description: 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.", "Returns market-specific keyword, URL, rank, search volume, CPC, intent, and traffic rows for a domain or page. Accepts country-level DataForSEO Labs location/language codes. Use this for strategy evidence; use get_domain_overview for aggregate domain footprint. Charges credits.",
inputSchema: getRankedKeywordsInputSchema, inputSchema: getRankedKeywordsInputSchema,
outputSchema: { outputSchema: {
keywords: z.array(looseObjectOutputSchema), keywords: z.array(looseObjectOutputSchema),
@ -608,7 +649,7 @@ export const getRankedKeywordsTool = {
handler: withMcpProjectAuth(async (args: GetRankedKeywordsArgs, context) => { handler: withMcpProjectAuth(async (args: GetRankedKeywordsArgs, context) => {
const client = createDataforseoClient(context.billing); const client = createDataforseoClient(context.billing);
const targetIsPage = /^https?:\/\//.test(args.target); const targetIsPage = /^https?:\/\//.test(args.target);
const market = resolveMarketSelector(args.market, context.project); const market = resolveMarketSelector(args, context.project);
const keywords = await client.domain.rankedKeywords({ const keywords = await client.domain.rankedKeywords({
target: args.target, target: args.target,
locationCode: market.locationCode, locationCode: market.locationCode,
@ -773,7 +814,7 @@ export const findSerpCompetitorsTool = {
config: { config: {
title: "Find SERP competitors", title: "Find SERP competitors",
description: 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.", "Compares domains competing in Google results for a supplied keyword set in a country-level DataForSEO Labs market. Accepts location/language codes; not radius-based local SEO. Charges credits.",
inputSchema: findSerpCompetitorsInputSchema, inputSchema: findSerpCompetitorsInputSchema,
outputSchema: { outputSchema: {
competitors: z.array(looseObjectOutputSchema), competitors: z.array(looseObjectOutputSchema),
@ -788,7 +829,7 @@ export const findSerpCompetitorsTool = {
handler: withMcpProjectAuth( handler: withMcpProjectAuth(
async (args: FindSerpCompetitorsArgs, context) => { async (args: FindSerpCompetitorsArgs, context) => {
const client = createDataforseoClient(context.billing); const client = createDataforseoClient(context.billing);
const market = resolveMarketSelector(args.market, context.project); const market = resolveMarketSelector(args, context.project);
const competitors = await client.labs.serpCompetitors({ const competitors = await client.labs.serpCompetitors({
keywords: args.keywords, keywords: args.keywords,
locationCode: market.locationCode, locationCode: market.locationCode,