From 002bd284c285389d668f5eae55ec5b58f66faf58 Mon Sep 17 00:00:00 2001 From: bookingseo <68512992+bookingseo@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:11:25 +0700 Subject: [PATCH 1/3] feat: configurable default market for location/language fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every location/language fallback was hardcoded to the United States (2840/en), which makes self-hosted deployments outside the US pass locationCode/languageCode on every single call. getDefaultMarket() reads OPENSEO_DEFAULT_LOCATION_CODE / OPENSEO_DEFAULT_LANGUAGE_CODE (language auto-derives from the location's native language; invalid values warn and fall back to the US so a typo can't break calls) and feeds the eight MCP tools that default these args. whoami reports the active defaultMarket so agents can discover it. The web UI's initial location honors VITE_DEFAULT_LOCATION_CODE; the per-browser preference still wins. Explicit args always win — the default only changes what omitted means. The Labs-only market tools (get_ranked_keywords, find_serp_competitors) follow the default when the market object is omitted, guarded so an Ads-provider default (e.g. Iceland) keeps them on the US instead of breaking. --- .env.example | 7 + compose.yaml | 6 + src/client/features/keywords/locations.ts | 19 ++- src/env.d.ts | 1 + src/server/lib/market-defaults.test.ts | 93 +++++++++++++ src/server/lib/market-defaults.ts | 87 ++++++++++++ src/server/mcp/schemas.ts | 7 +- .../dataforseo-research-tools.market.test.ts | 130 ++++++++++++++++++ .../mcp/tools/dataforseo-research-tools.ts | 62 ++++++--- .../tools/get-domain-keyword-suggestions.ts | 14 +- src/server/mcp/tools/get-domain-overview.ts | 14 +- src/server/mcp/tools/get-serp-results.ts | 8 +- src/server/mcp/tools/research-keywords.ts | 12 +- src/server/mcp/tools/save-keywords.ts | 8 +- src/server/mcp/tools/whoami.ts | 13 +- 15 files changed, 435 insertions(+), 46 deletions(-) create mode 100644 src/server/lib/market-defaults.test.ts create mode 100644 src/server/lib/market-defaults.ts create mode 100644 src/server/mcp/tools/dataforseo-research-tools.market.test.ts diff --git a/.env.example b/.env.example index ab00267e..ba8baffc 100644 --- a/.env.example +++ b/.env.example @@ -48,3 +48,10 @@ # GOOGLE_CLIENT_ID=replace-with-your-google-oauth-client-id # GOOGLE_CLIENT_SECRET=replace-with-your-google-oauth-client-secret # BETTER_AUTH_SECRET=replace-with-a-long-random-secret-at-least-32-characters + +# Optional: default market for calls that omit locationCode/languageCode +# (MCP tools) and the web UI's initial location. Example: Vietnam. +# Language defaults to the location's native language when unset. +# OPENSEO_DEFAULT_LOCATION_CODE=2704 +# OPENSEO_DEFAULT_LANGUAGE_CODE=vi +# VITE_DEFAULT_LOCATION_CODE=2704 diff --git a/compose.yaml b/compose.yaml index 87b1dad9..62c543e8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,6 +9,12 @@ services: - ALLOWED_HOST=${ALLOWED_HOST:-} - AUTH_MODE=local_noauth - DATAFORSEO_API_KEY=${DATAFORSEO_API_KEY} + # Default market when calls omit locationCode/languageCode (e.g. 2704 + vi + # for Vietnam). Language defaults to the location's native language. + - OPENSEO_DEFAULT_LOCATION_CODE=${OPENSEO_DEFAULT_LOCATION_CODE:-} + - OPENSEO_DEFAULT_LANGUAGE_CODE=${OPENSEO_DEFAULT_LANGUAGE_CODE:-} + # Web-UI initial location (inlined at the container's startup build). + - VITE_DEFAULT_LOCATION_CODE=${VITE_DEFAULT_LOCATION_CODE:-} # Optional: Google Search Console. See # docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} diff --git a/src/client/features/keywords/locations.ts b/src/client/features/keywords/locations.ts index 67bcc965..39ede793 100644 --- a/src/client/features/keywords/locations.ts +++ b/src/client/features/keywords/locations.ts @@ -1,7 +1,11 @@ // Location data lives in shared/ so the server can route keyword research by // provider (Labs vs Google Ads). This shim keeps existing client imports. +import { + DEFAULT_LOCATION_CODE as SHARED_DEFAULT_LOCATION_CODE, + isSupportedLocationCode, +} from "@/shared/keyword-locations"; + export { - DEFAULT_LOCATION_CODE, LABS_LOCATION_OPTIONS, LOCATIONS, getLanguageCode, @@ -9,3 +13,16 @@ export { isLabsLocationCode, isSupportedLocationCode, } from "@/shared/keyword-locations"; + +// A deployment serving another market can bake VITE_DEFAULT_LOCATION_CODE +// (e.g. 2704 Vietnam) into the client bundle; the per-browser preference in +// usePreferredKeywordLocation still wins once the user picks a location. +const envDefaultLocationCode = Number( + import.meta.env.VITE_DEFAULT_LOCATION_CODE ?? "", +); + +export const DEFAULT_LOCATION_CODE = + Number.isInteger(envDefaultLocationCode) && + isSupportedLocationCode(envDefaultLocationCode) + ? envDefaultLocationCode + : SHARED_DEFAULT_LOCATION_CODE; diff --git a/src/env.d.ts b/src/env.d.ts index 6f420ddb..2c724aa4 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -56,6 +56,7 @@ interface ImportMetaEnv { readonly TURNSTILE_SITE_KEY?: string; readonly VITE_E2E_DOMAIN_FIXTURES?: string; readonly VITE_E2E_KEYWORD_FIXTURES?: string; + readonly VITE_DEFAULT_LOCATION_CODE?: string; } interface ImportMeta { diff --git a/src/server/lib/market-defaults.test.ts b/src/server/lib/market-defaults.test.ts new file mode 100644 index 00000000..0286ceee --- /dev/null +++ b/src/server/lib/market-defaults.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getOptionalEnvValue: vi.fn( + async (_name: string): Promise => undefined, + ), +})); + +vi.mock("@/server/lib/runtime-env", () => ({ + getOptionalEnvValue: mocks.getOptionalEnvValue, +})); + +import { + getDefaultMarket, + resetDefaultMarketForTests, +} from "./market-defaults"; + +function setEnv(values: Record) { + mocks.getOptionalEnvValue.mockImplementation( + async (name: string) => values[name], + ); +} + +beforeEach(() => { + resetDefaultMarketForTests(); + vi.spyOn(console, "info").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +describe("getDefaultMarket", () => { + it("defaults to the United States when nothing is configured", async () => { + expect(await getDefaultMarket()).toEqual({ + locationCode: 2840, + languageCode: "en", + label: "United States", + }); + }); + + it("resolves Vietnam with its native language when only the location is set", async () => { + setEnv({ OPENSEO_DEFAULT_LOCATION_CODE: "2704" }); + expect(await getDefaultMarket()).toEqual({ + locationCode: 2704, + languageCode: "vi", + label: "Vietnam", + }); + }); + + it("honors an explicit language served for the location", async () => { + setEnv({ + OPENSEO_DEFAULT_LOCATION_CODE: "2704", + OPENSEO_DEFAULT_LANGUAGE_CODE: "en", + }); + expect(await getDefaultMarket()).toMatchObject({ + locationCode: 2704, + languageCode: "en", + }); + }); + + it("falls back to the location's native language when the pair is unserved", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + setEnv({ + OPENSEO_DEFAULT_LOCATION_CODE: "2704", + OPENSEO_DEFAULT_LANGUAGE_CODE: "ru", + }); + expect(await getDefaultMarket()).toMatchObject({ + locationCode: 2704, + languageCode: "vi", + }); + expect(warn).toHaveBeenCalled(); + }); + + it("falls back to the US defaults on an unsupported location code", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + setEnv({ OPENSEO_DEFAULT_LOCATION_CODE: "99999" }); + expect(await getDefaultMarket()).toMatchObject({ locationCode: 2840 }); + expect(warn).toHaveBeenCalled(); + }); + + it("treats empty strings as unset", async () => { + setEnv({ + OPENSEO_DEFAULT_LOCATION_CODE: "", + OPENSEO_DEFAULT_LANGUAGE_CODE: "", + }); + expect(await getDefaultMarket()).toMatchObject({ locationCode: 2840 }); + }); + + it("memoizes the resolved defaults", async () => { + await getDefaultMarket(); + await getDefaultMarket(); + // Two env vars read exactly once each. + expect(mocks.getOptionalEnvValue).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/server/lib/market-defaults.ts b/src/server/lib/market-defaults.ts new file mode 100644 index 00000000..661a94e6 --- /dev/null +++ b/src/server/lib/market-defaults.ts @@ -0,0 +1,87 @@ +import { getOptionalEnvValue } from "@/server/lib/runtime-env"; +import { + DEFAULT_LOCATION_CODE, + LOCATION_OPTIONS, + getKeywordDataProvider, + getLanguageCode, + getLanguageOptions, + isSupportedLocationCode, +} from "@/shared/keyword-locations"; + +/** + * Server-wide default market for tools that fall back when a call omits + * locationCode/languageCode. Historically hardcoded to the United States; + * a deployment serving another market (e.g. Vietnam) sets + * `OPENSEO_DEFAULT_LOCATION_CODE` (and optionally + * `OPENSEO_DEFAULT_LANGUAGE_CODE` — defaults to the location's native + * language). Invalid values fall back to the US defaults with a loud log, + * never an error: a typo must not take every default-market call down. + */ + +type MarketDefaults = { + locationCode: number; + languageCode: string; + label: string; +}; + +const US_DEFAULTS: MarketDefaults = { + locationCode: DEFAULT_LOCATION_CODE, + languageCode: "en", + label: "United States", +}; + +let memo: Promise | null = null; + +export function getDefaultMarket(): Promise { + memo ??= resolveDefaultMarket(); + return memo; +} + +/** Test hook: clears the module-level memo. */ +export function resetDefaultMarketForTests(): void { + memo = null; +} + +async function resolveDefaultMarket(): Promise { + const rawLocation = + (await getOptionalEnvValue("OPENSEO_DEFAULT_LOCATION_CODE")) || undefined; + const rawLanguage = + (await getOptionalEnvValue("OPENSEO_DEFAULT_LANGUAGE_CODE")) || undefined; + if (rawLocation == null && rawLanguage == null) return US_DEFAULTS; + + const locationCode = + rawLocation == null ? US_DEFAULTS.locationCode : Number(rawLocation); + if ( + !Number.isInteger(locationCode) || + !isSupportedLocationCode(locationCode) + ) { + console.warn( + `OPENSEO_DEFAULT_LOCATION_CODE="${rawLocation}" is not a supported DataForSEO location code; using ${US_DEFAULTS.locationCode} (United States)`, + ); + return US_DEFAULTS; + } + + const nativeLanguage = getLanguageCode(locationCode); + let languageCode = rawLanguage ?? nativeLanguage; + if ( + getKeywordDataProvider(locationCode) === "labs" && + !getLanguageOptions(locationCode).some( + (option) => option.code === languageCode, + ) + ) { + console.warn( + `OPENSEO_DEFAULT_LANGUAGE_CODE="${rawLanguage}" is not served for location ${locationCode}; using its native language "${nativeLanguage}"`, + ); + languageCode = nativeLanguage; + } + + const defaults: MarketDefaults = { + locationCode, + languageCode, + label: + LOCATION_OPTIONS.find((option) => option.code === locationCode)?.label ?? + String(locationCode), + }; + console.info(JSON.stringify({ evt: "openseo_default_market", ...defaults })); + return defaults; +} diff --git a/src/server/mcp/schemas.ts b/src/server/mcp/schemas.ts index 7c95deac..c49f0754 100644 --- a/src/server/mcp/schemas.ts +++ b/src/server/mcp/schemas.ts @@ -7,7 +7,6 @@ import { } from "@/shared/keyword-locations"; export const DEFAULT_LOCATION_CODE = 2840; -export const DEFAULT_LANGUAGE_CODE = "en"; export const projectIdSchema = z .string() @@ -21,7 +20,7 @@ export const locationCodeSchema = z .int() .positive() .describe( - "DataForSEO location code. Defaults to 2840 (United States). See dataforseo.com/help-center/locations. Some countries (e.g. Iceland, 2352) are served from Google Ads data: keyword volume/CPC/trends work, but keyword difficulty, search intent, and domain analytics are unavailable.", + "DataForSEO location code. Defaults to the server's default market (2840 United States unless the deployment overrides it — check whoami). See dataforseo.com/help-center/locations. Some countries (e.g. Iceland, 2352) are served from Google Ads data: keyword volume/CPC/trends work, but keyword difficulty, search intent, and domain analytics are unavailable.", ); /** @@ -69,4 +68,6 @@ export const languageCodeSchema = z message: "Unsupported language code. Use a supported code such as 'en', 'es', 'de', or 'fr'.", }) - .describe("Language code (e.g. 'en', 'es', 'fr'). Defaults to 'en'."); + .describe( + "Language code (e.g. 'en', 'es', 'vi'). Defaults to the server's default market language (check whoami).", + ); diff --git a/src/server/mcp/tools/dataforseo-research-tools.market.test.ts b/src/server/mcp/tools/dataforseo-research-tools.market.test.ts new file mode 100644 index 00000000..65ebe6e4 --- /dev/null +++ b/src/server/mcp/tools/dataforseo-research-tools.market.test.ts @@ -0,0 +1,130 @@ +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 { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; + +// Market resolution for the Labs-only tools (get_ranked_keywords, +// find_serp_competitors): the explicit country selector and the server's +// default-market fallback. + +const mocks = vi.hoisted(() => ({ + createDataforseoClient: vi.fn(), + getProjectForOrganization: vi.fn(), + getDefaultMarket: vi.fn(async () => ({ + locationCode: 2840, + languageCode: "en", + label: "United States", + })), +})); + +vi.mock("cloudflare:workers", () => ({ env: {} })); + +vi.mock("@/server/lib/dataforseo", () => ({ + createDataforseoClient: mocks.createDataforseoClient, + fetchKeywordMetricsForList: vi.fn(), +})); + +vi.mock("@/server/lib/market-defaults", () => ({ + getDefaultMarket: mocks.getDefaultMarket, +})); + +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, +}; + +async function runRankedKeywords(args: { market?: { country: "US" } }) { + 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", ...args }, + toolExtra, + ); + return rankedKeywords; +} + +describe("market resolution for Labs tools", () => { + beforeEach(() => { + vi.resetModules(); + mocks.createDataforseoClient.mockReset(); + mocks.getProjectForOrganization.mockReset(); + mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" }); + mocks.getDefaultMarket.mockResolvedValue({ + locationCode: 2840, + languageCode: "en", + label: "United States", + }); + }); + + it("keeps the US when market is explicit even under a non-US default", async () => { + mocks.getDefaultMarket.mockResolvedValue({ + locationCode: 2704, + languageCode: "vi", + label: "Vietnam", + }); + const rankedKeywords = await runRankedKeywords({ + market: { country: "US" }, + }); + expect(rankedKeywords).toHaveBeenCalledWith( + expect.objectContaining({ locationCode: 2840, languageCode: "en" }), + ); + }); + + it("follows the server's default market when the market object is omitted", async () => { + mocks.getDefaultMarket.mockResolvedValue({ + locationCode: 2704, + languageCode: "vi", + label: "Vietnam", + }); + const rankedKeywords = await runRankedKeywords({}); + expect(rankedKeywords).toHaveBeenCalledWith( + expect.objectContaining({ locationCode: 2704, languageCode: "vi" }), + ); + }); + + it("falls back to the US when the default market is not Labs-served", async () => { + // Iceland (2352) is served from Google Ads data; the Labs-only market + // tools must not inherit it. + mocks.getDefaultMarket.mockResolvedValue({ + locationCode: 2352, + languageCode: "en", + label: "Iceland", + }); + const rankedKeywords = await runRankedKeywords({}); + expect(rankedKeywords).toHaveBeenCalledWith( + expect.objectContaining({ locationCode: 2840, languageCode: "en" }), + ); + }); +}); diff --git a/src/server/mcp/tools/dataforseo-research-tools.ts b/src/server/mcp/tools/dataforseo-research-tools.ts index 7d796265..48c0bfc4 100644 --- a/src/server/mcp/tools/dataforseo-research-tools.ts +++ b/src/server/mcp/tools/dataforseo-research-tools.ts @@ -17,8 +17,9 @@ import { readPath, type McpTableColumn, } from "@/server/mcp/table"; +import { getDefaultMarket } from "@/server/lib/market-defaults"; +import { getKeywordDataProvider } from "@/shared/keyword-locations"; import { - DEFAULT_LANGUAGE_CODE, DEFAULT_LOCATION_CODE, assertLanguageForLocation, languageCodeSchema, @@ -46,10 +47,14 @@ const marketSchema = z country: z .enum(["US", "USA", "United States", "United States of America"]) .optional() - .describe("Country selector. Only the United States is supported."), + .describe( + "Country selector. Only the United States can be selected explicitly.", + ), }) .optional() - .describe("Optional United States market object. Defaults to United States."); + .describe( + "Optional market object. Omitted = the server's default market (United States unless the deployment overrides it).", + ); const nearSchema = z .object({ @@ -344,10 +349,27 @@ type GetGoogleBusinessQuestionsArgs = z.infer< 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; +/** + * Resolves the market selector to a Labs location + language. An explicit + * country wins; omitted falls back to the server's default market, but only + * when that default is Labs-served (these two tools are Labs-only - an + * Ads-provider default like Iceland must not silently break them). + */ +async function resolveMarket( + market: Market | undefined, +): Promise<{ locationCode: number; languageCode: string }> { + if (market?.country != null) { + // The Zod enum already restricts explicit values to United States variants. + return { locationCode: DEFAULT_LOCATION_CODE, languageCode: "en" }; + } + const defaults = await getDefaultMarket(); + if (getKeywordDataProvider(defaults.locationCode) !== "labs") { + return { locationCode: DEFAULT_LOCATION_CODE, languageCode: "en" }; + } + return { + locationCode: defaults.locationCode, + languageCode: defaults.languageCode, + }; } function formatCoordinate(value: number): string { @@ -594,10 +616,11 @@ export const getRankedKeywordsTool = { handler: withMcpProjectAuth(async (args: GetRankedKeywordsArgs, context) => { const client = createDataforseoClient(context.billing); const targetIsPage = /^https?:\/\//.test(args.target); + const market = await resolveMarket(args.market); const keywords = await client.domain.rankedKeywords({ target: args.target, - locationCode: resolveMarketLocationCode(args.market), - languageCode: DEFAULT_LANGUAGE_CODE, + locationCode: market.locationCode, + languageCode: market.languageCode, limit: args.limit ?? 50, offset: args.offset, orderBy: sortOrderByRankedMode(args.sortBy), @@ -693,7 +716,8 @@ export const getLocalSerpResultsTool = { const results = await client.serp.local({ keyword: args.keyword, locationCoordinate: formatLocalSerpCoordinate(args.near), - languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE, + languageCode: + args.languageCode ?? (await getDefaultMarket()).languageCode, searchType: args.searchType ?? "maps", device: args.device ?? "desktop", depth: args.depth ?? 20, @@ -736,7 +760,8 @@ export const getGoogleBusinessQuestionsTool = { const questions = await client.business.questionsAnswers({ keyword: args.keyword, locationCoordinate: formatQuestionsAnswersCoordinate(args.near), - languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE, + languageCode: + args.languageCode ?? (await getDefaultMarket()).languageCode, depth: args.depth ?? 20, }); @@ -773,10 +798,11 @@ export const findSerpCompetitorsTool = { handler: withMcpProjectAuth( async (args: FindSerpCompetitorsArgs, context) => { const client = createDataforseoClient(context.billing); + const market = await resolveMarket(args.market); const competitors = await client.labs.serpCompetitors({ keywords: args.keywords, - locationCode: resolveMarketLocationCode(args.market), - languageCode: DEFAULT_LANGUAGE_CODE, + locationCode: market.locationCode, + languageCode: market.languageCode, itemTypes: args.resultTypes ?? ["organic", "local_pack"], includeSubdomains: args.includeSubdomains, limit: args.limit ?? 50, @@ -829,10 +855,14 @@ export const getKeywordMetricsTool = { }, }, handler: withMcpProjectAuth(async (args: GetKeywordMetricsArgs, context) => { - assertLanguageForLocation(args.locationCode, args.languageCode); + const defaultMarket = await getDefaultMarket(); + const locationCode = args.locationCode ?? defaultMarket.locationCode; + const languageCode = args.languageCode ?? defaultMarket.languageCode; + // Assert against the RESOLVED pair: with a non-US default market, an + // explicit language and an omitted location must validate against the + // default location, not the hardcoded US fallback. + assertLanguageForLocation(locationCode, languageCode); const client = createDataforseoClient(context.billing); - const locationCode = args.locationCode ?? DEFAULT_LOCATION_CODE; - const languageCode = args.languageCode ?? DEFAULT_LANGUAGE_CODE; const metrics = await fetchKeywordMetricsForList(client, { keywords: args.keywords, locationCode, diff --git a/src/server/mcp/tools/get-domain-keyword-suggestions.ts b/src/server/mcp/tools/get-domain-keyword-suggestions.ts index 14f1187a..43348f22 100644 --- a/src/server/mcp/tools/get-domain-keyword-suggestions.ts +++ b/src/server/mcp/tools/get-domain-keyword-suggestions.ts @@ -12,9 +12,8 @@ import { readPath, type McpTableColumn, } from "@/server/mcp/table"; +import { getDefaultMarket } from "@/server/lib/market-defaults"; import { - DEFAULT_LANGUAGE_CODE, - DEFAULT_LOCATION_CODE, assertLabsLocationCode, assertLanguageForLocation, languageCodeSchema, @@ -59,13 +58,16 @@ export const getDomainKeywordSuggestionsTool = { }, }, handler: withMcpProjectAuth(async (args: Args, context) => { - assertLabsLocationCode(args.locationCode); - assertLanguageForLocation(args.locationCode, args.languageCode); + const defaultMarket = await getDefaultMarket(); + const locationCode = args.locationCode ?? defaultMarket.locationCode; + const languageCode = args.languageCode ?? defaultMarket.languageCode; + assertLabsLocationCode(locationCode); + assertLanguageForLocation(locationCode, languageCode); const keywords = await DomainService.getSuggestedKeywords( { domain: args.domain, - locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE, - languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE, + locationCode, + languageCode, organizationId: context.auth.organizationId, projectId: args.projectId, }, diff --git a/src/server/mcp/tools/get-domain-overview.ts b/src/server/mcp/tools/get-domain-overview.ts index 4970562f..9afb98fc 100644 --- a/src/server/mcp/tools/get-domain-overview.ts +++ b/src/server/mcp/tools/get-domain-overview.ts @@ -4,9 +4,8 @@ import { mcpResponse } from "@/server/mcp/formatters"; import { buildProjectMeta } from "@/server/mcp/context"; import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; +import { getDefaultMarket } from "@/server/lib/market-defaults"; import { - DEFAULT_LANGUAGE_CODE, - DEFAULT_LOCATION_CODE, assertLabsLocationCode, assertLanguageForLocation, languageCodeSchema, @@ -52,15 +51,18 @@ export const getDomainOverviewTool = { }, }, handler: withMcpProjectAuth(async (args: Args, context) => { - assertLabsLocationCode(args.locationCode); - assertLanguageForLocation(args.locationCode, args.languageCode); + const defaultMarket = await getDefaultMarket(); + const locationCode = args.locationCode ?? defaultMarket.locationCode; + const languageCode = args.languageCode ?? defaultMarket.languageCode; + assertLabsLocationCode(locationCode); + assertLanguageForLocation(locationCode, languageCode); const result = await DomainService.getOverview( { projectId: args.projectId, domain: args.domain, includeSubdomains: args.includeSubdomains, - locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE, - languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE, + locationCode, + languageCode, }, context.billing, ); diff --git a/src/server/mcp/tools/get-serp-results.ts b/src/server/mcp/tools/get-serp-results.ts index 93e3a924..48d895ef 100644 --- a/src/server/mcp/tools/get-serp-results.ts +++ b/src/server/mcp/tools/get-serp-results.ts @@ -5,9 +5,8 @@ import { buildProjectMeta } from "@/server/mcp/context"; import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table"; +import { getDefaultMarket } from "@/server/lib/market-defaults"; import { - DEFAULT_LANGUAGE_CODE, - DEFAULT_LOCATION_CODE, languageCodeSchema, locationCodeSchema, projectIdSchema, @@ -95,14 +94,15 @@ export const getSerpResultsTool = { }, handler: withMcpProjectAuth(async (args: Args, context) => { const client = createDataforseoClient(context.billing); + const defaultMarket = await getDefaultMarket(); const results = await Promise.all( args.queries.map(async (q) => { try { const items = await client.serp.live({ keyword: q.keyword, - locationCode: q.locationCode ?? DEFAULT_LOCATION_CODE, - languageCode: q.languageCode ?? DEFAULT_LANGUAGE_CODE, + locationCode: q.locationCode ?? defaultMarket.locationCode, + languageCode: q.languageCode ?? defaultMarket.languageCode, }); // Trim noise — return only essentials per item. const trimmed = items.slice(0, 20).map((item) => ({ diff --git a/src/server/mcp/tools/research-keywords.ts b/src/server/mcp/tools/research-keywords.ts index 503e2edf..16a90dc3 100644 --- a/src/server/mcp/tools/research-keywords.ts +++ b/src/server/mcp/tools/research-keywords.ts @@ -8,9 +8,8 @@ import { } from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table"; +import { getDefaultMarket } from "@/server/lib/market-defaults"; import { - DEFAULT_LANGUAGE_CODE, - DEFAULT_LOCATION_CODE, assertLanguageForLocation, languageCodeSchema, locationCodeSchema, @@ -105,16 +104,19 @@ export const researchKeywordsTool = { }, }, handler: withMcpProjectAuth(async (args: Args, context) => { + const defaultMarket = await getDefaultMarket(); const results = await Promise.all( args.seeds.map(async (item) => { try { - assertLanguageForLocation(item.locationCode, item.languageCode); + const locationCode = item.locationCode ?? defaultMarket.locationCode; + const languageCode = item.languageCode ?? defaultMarket.languageCode; + assertLanguageForLocation(locationCode, languageCode); const data = await KeywordResearchService.research( { projectId: args.projectId, keywords: [item.seed], - locationCode: item.locationCode ?? DEFAULT_LOCATION_CODE, - languageCode: item.languageCode ?? DEFAULT_LANGUAGE_CODE, + locationCode, + languageCode, resultLimit: args.resultLimit ?? 150, mode: "auto", clickstream: args.includeClickstreamData ?? false, diff --git a/src/server/mcp/tools/save-keywords.ts b/src/server/mcp/tools/save-keywords.ts index 65891af7..6cd1bc90 100644 --- a/src/server/mcp/tools/save-keywords.ts +++ b/src/server/mcp/tools/save-keywords.ts @@ -4,9 +4,8 @@ import { mcpResponse } from "@/server/mcp/formatters"; import { buildProjectMeta } from "@/server/mcp/context"; import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; +import { getDefaultMarket } from "@/server/lib/market-defaults"; import { - DEFAULT_LANGUAGE_CODE, - DEFAULT_LOCATION_CODE, languageCodeSchema, locationCodeSchema, projectIdSchema, @@ -66,8 +65,9 @@ export const saveKeywordsTool = { throw new Error("Replacement tags are required when tagMode is replace."); } - const locationCode = args.locationCode ?? DEFAULT_LOCATION_CODE; - const languageCode = args.languageCode ?? DEFAULT_LANGUAGE_CODE; + const defaultMarket = await getDefaultMarket(); + const locationCode = args.locationCode ?? defaultMarket.locationCode; + const languageCode = args.languageCode ?? defaultMarket.languageCode; await KeywordResearchService.saveKeywords({ projectId: args.projectId, diff --git a/src/server/mcp/tools/whoami.ts b/src/server/mcp/tools/whoami.ts index ea556449..c3c1b5ab 100644 --- a/src/server/mcp/tools/whoami.ts +++ b/src/server/mcp/tools/whoami.ts @@ -5,6 +5,7 @@ import { } from "@/shared/billing"; import { mcpResponse } from "@/server/mcp/formatters"; import { getAuth, type ToolExtra } from "@/server/mcp/context"; +import { getDefaultMarket } from "@/server/lib/market-defaults"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { z } from "zod"; @@ -23,7 +24,7 @@ export const whoamiTool = { config: { title: "Who am I", description: - "Returns the authenticated user, organization, server mode, token scopes, and current credit balance. Uses no credits — does not call DataForSEO. Use this first to confirm connection context before choosing a project or running paid tools.", + "Returns the authenticated user, organization, server mode, token scopes, current credit balance, and the server's default market (the location/language used when calls omit locationCode/languageCode). Uses no credits — does not call DataForSEO. Use this first to confirm connection context before choosing a project or running paid tools.", inputSchema: {} as Record, outputSchema: { userId: z.string(), @@ -32,6 +33,13 @@ export const whoamiTool = { scopes: z.array(z.string()), mode: z.enum(["hosted", "self-hosted"]), creditsRemaining: z.number().nullable(), + defaultMarket: z + .object({ + locationCode: z.number(), + languageCode: z.string(), + label: z.string(), + }) + .optional(), ...optionalMetaOutputSchema, }, annotations: { @@ -43,6 +51,7 @@ export const whoamiTool = { handler: async (_args: Record, extra: ToolExtra) => { const auth = getAuth(extra); const isHosted = await isHostedServerAuthMode(); + const defaultMarket = await getDefaultMarket(); let creditsRemaining: number | null = null; if (isHosted) { const [base, topup] = await Promise.all([ @@ -59,6 +68,7 @@ export const whoamiTool = { `Organization: ${auth.organizationId}`, `Mode: ${isHosted ? "hosted" : "self-hosted"}`, `Scopes: ${auth.scopes.length > 0 ? auth.scopes.join(", ") : "none"}`, + `Default market: ${defaultMarket.label} (locationCode ${defaultMarket.locationCode}, language '${defaultMarket.languageCode}') — used when calls omit locationCode/languageCode`, ]; if (isHosted) { lines.push( @@ -78,6 +88,7 @@ export const whoamiTool = { scopes: auth.scopes, mode: isHosted ? "hosted" : "self-hosted", creditsRemaining, + defaultMarket, }, }); }, From c72b85d15182ccf61dceaf8f3c04f94d77b565ad Mon Sep 17 00:00:00 2001 From: bookingseo <68512992+bookingseo@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:11:26 +0700 Subject: [PATCH 2/3] refactor: per-project default market instead of env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: the projects table already carries locationCode/languageCode ('set during onboarding and reused by every project-scoped data call'), so the default market belongs there — no schema change, no env vars. - withMcpProjectAuth exposes the project row it already fetches for the auth gate; the eight defaulting tools fall back to project.locationCode/languageCode (zero extra queries). - list_projects returns each project's locationCode/languageCode (the whoami field from the previous commit is reverted — an org-level tool has no single project to speak for). - CreateProjectModal gains a default-market picker (language derives from the location's native language); ProjectSettings edits both, with the language list constrained to what DataForSEO serves for the location. The keyword picker seeds from the project default; the per-browser preference still wins once set. - createProject/updateProject accept optional locationCode/languageCode; a language-only update is validated against the stored location so an unserved pair is rejected free instead of failing as a charged provider error. - The Labs-only market tools follow the project default only when Labs serves it (an Ads-data market like Iceland falls back to the US). --- .env.example | 7 -- compose.yaml | 6 -- .../hooks/usePreferredKeywordLocation.ts | 20 +++- src/client/features/keywords/locations.ts | 19 +--- .../state/keywordControllerInternals.ts | 14 ++- .../features/projects/CreateProjectModal.tsx | 20 +++- .../features/projects/ProjectSettings.tsx | 50 +++++++++- src/client/features/projects/types.ts | 3 + src/env.d.ts | 1 - .../repositories/ProjectRepository.ts | 11 ++- .../projects/services/projects.test.ts | 54 +++++++++++ .../features/projects/services/projects.ts | 67 ++++++++++++- src/server/lib/market-defaults.test.ts | 93 ------------------- src/server/lib/market-defaults.ts | 87 ----------------- src/server/mcp/project-auth.test.ts | 10 +- src/server/mcp/project-auth.ts | 3 + src/server/mcp/schemas.ts | 4 +- ...taforseo-research-tools.google-ads.test.ts | 6 +- .../dataforseo-research-tools.market.test.ts | 54 ++++------- .../tools/dataforseo-research-tools.test.ts | 8 +- .../mcp/tools/dataforseo-research-tools.ts | 35 ++++--- .../tools/get-domain-keyword-suggestions.ts | 7 +- src/server/mcp/tools/get-domain-overview.ts | 7 +- src/server/mcp/tools/get-serp-results.ts | 7 +- src/server/mcp/tools/list-projects.ts | 9 +- .../tools/output-schema-validation.test.ts | 6 +- src/server/mcp/tools/research-keywords.ts | 7 +- src/server/mcp/tools/save-keywords.ts | 7 +- .../mcp/tools/saved-keywords-tools.test.ts | 6 +- .../mcp/tools/search-console-tools.test.ts | 6 +- src/server/mcp/tools/tool-text-output.test.ts | 6 +- src/server/mcp/tools/whoami.ts | 13 +-- src/types/schemas/projects.ts | 22 +++++ 33 files changed, 353 insertions(+), 322 deletions(-) delete mode 100644 src/server/lib/market-defaults.test.ts delete mode 100644 src/server/lib/market-defaults.ts diff --git a/.env.example b/.env.example index ba8baffc..ab00267e 100644 --- a/.env.example +++ b/.env.example @@ -48,10 +48,3 @@ # GOOGLE_CLIENT_ID=replace-with-your-google-oauth-client-id # GOOGLE_CLIENT_SECRET=replace-with-your-google-oauth-client-secret # BETTER_AUTH_SECRET=replace-with-a-long-random-secret-at-least-32-characters - -# Optional: default market for calls that omit locationCode/languageCode -# (MCP tools) and the web UI's initial location. Example: Vietnam. -# Language defaults to the location's native language when unset. -# OPENSEO_DEFAULT_LOCATION_CODE=2704 -# OPENSEO_DEFAULT_LANGUAGE_CODE=vi -# VITE_DEFAULT_LOCATION_CODE=2704 diff --git a/compose.yaml b/compose.yaml index 62c543e8..87b1dad9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,12 +9,6 @@ services: - ALLOWED_HOST=${ALLOWED_HOST:-} - AUTH_MODE=local_noauth - DATAFORSEO_API_KEY=${DATAFORSEO_API_KEY} - # Default market when calls omit locationCode/languageCode (e.g. 2704 + vi - # for Vietnam). Language defaults to the location's native language. - - OPENSEO_DEFAULT_LOCATION_CODE=${OPENSEO_DEFAULT_LOCATION_CODE:-} - - OPENSEO_DEFAULT_LANGUAGE_CODE=${OPENSEO_DEFAULT_LANGUAGE_CODE:-} - # Web-UI initial location (inlined at the container's startup build). - - VITE_DEFAULT_LOCATION_CODE=${VITE_DEFAULT_LOCATION_CODE:-} # Optional: Google Search Console. See # docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} diff --git a/src/client/features/keywords/hooks/usePreferredKeywordLocation.ts b/src/client/features/keywords/hooks/usePreferredKeywordLocation.ts index a27dcd4f..ae20b45e 100644 --- a/src/client/features/keywords/hooks/usePreferredKeywordLocation.ts +++ b/src/client/features/keywords/hooks/usePreferredKeywordLocation.ts @@ -28,21 +28,31 @@ function savePreferredLocationCode(locationCode: number) { } } -export function usePreferredKeywordLocation() { - const [preferredLocationCode, setPreferredLocationCodeState] = useState( - DEFAULT_LOCATION_CODE, +/** + * Preference order: the user's explicit choice (persisted per browser) > + * the project's default market (may arrive async from the projects query) > + * the US fallback. + */ +export function usePreferredKeywordLocation( + projectDefaultLocationCode?: number, +) { + const [chosenLocationCode, setChosenLocationCode] = useState( + null, ); useEffect(() => { const savedLocationCode = loadPreferredLocationCode(); if (savedLocationCode != null) { - setPreferredLocationCodeState(savedLocationCode); + setChosenLocationCode(savedLocationCode); } }, []); + const preferredLocationCode = + chosenLocationCode ?? projectDefaultLocationCode ?? DEFAULT_LOCATION_CODE; + function setPreferredLocationCode(locationCode: number) { if (!isSupportedLocationCode(locationCode)) return; - setPreferredLocationCodeState(locationCode); + setChosenLocationCode(locationCode); savePreferredLocationCode(locationCode); } diff --git a/src/client/features/keywords/locations.ts b/src/client/features/keywords/locations.ts index 39ede793..67bcc965 100644 --- a/src/client/features/keywords/locations.ts +++ b/src/client/features/keywords/locations.ts @@ -1,11 +1,7 @@ // Location data lives in shared/ so the server can route keyword research by // provider (Labs vs Google Ads). This shim keeps existing client imports. -import { - DEFAULT_LOCATION_CODE as SHARED_DEFAULT_LOCATION_CODE, - isSupportedLocationCode, -} from "@/shared/keyword-locations"; - export { + DEFAULT_LOCATION_CODE, LABS_LOCATION_OPTIONS, LOCATIONS, getLanguageCode, @@ -13,16 +9,3 @@ export { isLabsLocationCode, isSupportedLocationCode, } from "@/shared/keyword-locations"; - -// A deployment serving another market can bake VITE_DEFAULT_LOCATION_CODE -// (e.g. 2704 Vietnam) into the client bundle; the per-browser preference in -// usePreferredKeywordLocation still wins once the user picks a location. -const envDefaultLocationCode = Number( - import.meta.env.VITE_DEFAULT_LOCATION_CODE ?? "", -); - -export const DEFAULT_LOCATION_CODE = - Number.isInteger(envDefaultLocationCode) && - isSupportedLocationCode(envDefaultLocationCode) - ? envDefaultLocationCode - : SHARED_DEFAULT_LOCATION_CODE; diff --git a/src/client/features/keywords/state/keywordControllerInternals.ts b/src/client/features/keywords/state/keywordControllerInternals.ts index 13e59d63..588d580e 100644 --- a/src/client/features/keywords/state/keywordControllerInternals.ts +++ b/src/client/features/keywords/state/keywordControllerInternals.ts @@ -1,8 +1,9 @@ import { useNavigate } from "@tanstack/react-router"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { usePreferredKeywordLocation } from "@/client/features/keywords/hooks/usePreferredKeywordLocation"; import { saveKeywords } from "@/serverFunctions/keywords"; +import { getProjects } from "@/serverFunctions/projects"; import type { SaveKeywordsInput } from "@/types/schemas/keywords"; import type { KeywordResearchRow } from "@/types/keywords"; import type { KeywordResearchControllerInput } from "./useKeywordResearchController"; @@ -10,8 +11,17 @@ import type { KeywordResearchControllerInput } from "./useKeywordResearchControl export function useResolvedKeywordLocation( input: KeywordResearchControllerInput, ) { + // Seed the picker from the project's default market (cached under + // ["projects"], shared with the switcher/settings) until the user picks one. + const projectsQuery = useQuery({ + queryKey: ["projects"], + queryFn: () => getProjects(), + }); + const projectDefaultLocationCode = projectsQuery.data?.find( + (project) => project.id === input.projectId, + )?.locationCode; const { preferredLocationCode, setPreferredLocationCode } = - usePreferredKeywordLocation(); + usePreferredKeywordLocation(projectDefaultLocationCode); const locationCode = !input.hasExplicitLocationCode && input.keywordInput === "" ? preferredLocationCode diff --git a/src/client/features/projects/CreateProjectModal.tsx b/src/client/features/projects/CreateProjectModal.tsx index 23f248e0..081890ac 100644 --- a/src/client/features/projects/CreateProjectModal.tsx +++ b/src/client/features/projects/CreateProjectModal.tsx @@ -2,9 +2,11 @@ import * as React from "react"; import { useNavigate } from "@tanstack/react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { LocationSelect } from "@/client/components/LocationSelect"; import { Modal } from "@/client/components/Modal"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { setLastProjectId } from "@/client/lib/active-project"; +import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations"; import { createProject } from "@/serverFunctions/projects"; export function CreateProjectModal({ onClose }: { onClose: () => void }) { @@ -12,11 +14,18 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) { const queryClient = useQueryClient(); const [name, setName] = React.useState(""); const [domain, setDomain] = React.useState(""); + const [locationCode, setLocationCode] = React.useState(DEFAULT_LOCATION_CODE); const createMutation = useMutation({ mutationFn: () => createProject({ - data: { name: name.trim(), domain: domain.trim() || undefined }, + // Language auto-derives server-side from the location's native + // language; the settings page offers the full per-location list. + data: { + name: name.trim(), + domain: domain.trim() || undefined, + locationCode, + }, }), onSuccess: async (created) => { setLastProjectId(created.id); @@ -88,6 +97,15 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) { + +