Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
41e3b6c
feat(lib): route Automate, App Automate, Accessibility and Observabil…
SavioBS629 Oct 16, 2026
94dc231
fix(lib): fall back to the US host when no region accepts the account
SavioBS629 Oct 16, 2026
5df4624
feat(lib): choose the GRR region with BROWSERSTACK_GRR_REGION instead…
SavioBS629 Oct 16, 2026
1666f61
docs: document BROWSERSTACK_GRR_REGION in llms-install.md
SavioBS629 Oct 16, 2026
dc36a28
refactor(lib): trim GRR routing comments
SavioBS629 Oct 16, 2026
c33a466
Merge main into feat/grr-regional-routing
SavioBS629 Oct 19, 2026
94a2a18
feat(lib): route the TRA capability by BROWSERSTACK_GRR_REGION
SavioBS629 Oct 19, 2026
2ee3537
feat(testmanagement): let BROWSERSTACK_GRR_REGION set the TM probe order
SavioBS629 Oct 22, 2026
a214729
refactor(lib): route every product, TM included, through the BROWSERS…
SavioBS629 Oct 22, 2026
9182205
feat(lib): send only usage fields in telemetry when BROWSERSTACK_GRR_…
SavioBS629 Oct 27, 2026
9cbd76d
feat(server): leave out submitFeedback when BROWSERSTACK_GRR_REGION i…
SavioBS629 Oct 28, 2026
914fc5d
fix(testmanagement): build the updateTestCase dashboard link from the…
SavioBS629 Oct 28, 2026
d9cc043
refactor(lib): share one non-US region check and default host params …
SavioBS629 Oct 28, 2026
3559b0c
Merge main into feat/grr-regional-routing
SavioBS629 Oct 28, 2026
374d638
feat(lib): route the app_accessibility capability by BROWSERSTACK_GRR…
SavioBS629 Oct 28, 2026
53e52b0
feat(lib): use the configured region for capability hosts before disc…
SavioBS629 Oct 28, 2026
40928fb
fix(lib): record capability entity and product only when known
SavioBS629 Oct 28, 2026
c9a90ea
Merge remote-tracking branch 'upstream/main' into feat/grr-regional-r…
SavioBS629 Oct 29, 2026
24266f6
fix(accessibility): route the scanner link and web_accessibility capa…
SavioBS629 Oct 29, 2026
30dafab
fix(lib): address region routing review
SavioBS629 Oct 29, 2026
0941eb0
feat(lib): detect the account's region from BrowserStack auth
SavioBS629 Oct 29, 2026
b3a6771
refactor(lib): take the region only from the account
SavioBS629 Oct 29, 2026
1efef0d
fix(lib): retry a failed account region lookup
SavioBS629 Oct 29, 2026
df5fef1
feat(lib): send no telemetry for EU and IN accounts
SavioBS629 Oct 29, 2026
f09c3c5
refactor(lib): route only EU accounts to regional hosts
SavioBS629 Oct 29, 2026
4eabd41
fix(lib): refuse to pick a host when the account's region is unknown
SavioBS629 Oct 29, 2026
1851231
perf(lib): share the region lookup between servers for one account
SavioBS629 Oct 29, 2026
390c066
fix(lib): keep the last known region when a refresh fails
SavioBS629 Oct 30, 2026
e3e9656
feat(lib): find the account's region by probing when auth can't answer
SavioBS629 Oct 30, 2026
84e1ff7
refactor(lib): keep telemetry and feedback the same for every region
SavioBS629 Nov 3, 2026
51e298e
Merge remote-tracking branch 'upstream/main' into feat/grr-regional-r…
SavioBS629 Nov 3, 2026
582aa2a
revert(testmanagement): leave capability registry telemetry as on main
SavioBS629 Nov 3, 2026
5996db5
fix(lib): retry an unrecognised region instead of keeping it for an hour
SavioBS629 Nov 3, 2026
4bbb4c8
fix(lib): probe only Test Management when auth can't answer
SavioBS629 Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions src/lib/account-region.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { createHash } from "node:crypto";
import logger from "../logger.js";
import { BrowserStackConfig } from "./types.js";
import { fetchTokenTransport, mintCentralToken } from "./central-oauth.js";
import { AccountRegion, GRR_REGIONS, GrrRegion } from "./region-context.js";

const AUTH_TOKEN_URL = "https://auth.browserstack.com/oauth2/v2/token";
const SCOPE = "oauth_user_profile";
const LOOKUP_TIMEOUT_MS = 5_000;
/** How long a failed lookup answers "unknown" before the next call tries again. */
export const RETRY_AFTER_MS = 5_000;
/** How long a looked-up region is reused before it is looked up again. */
export const REGION_TTL_MS = 60 * 60_000;
const MAX_ENTRIES = 1_000;

interface Lookup {
settled?: AccountRegion;
settledAt?: number;
pending?: Promise<AccountRegion>;
failedAt?: number;
}

// Keyed on a hash of the credentials, so servers built per request for the same account
// share one lookup. Holds only the region; bounded, oldest entry evicted first.
const lookups = new Map<string, Lookup>();

function keyFor(config: BrowserStackConfig): string {
return createHash("sha256")
.update(
`${config["browserstack-username"]}\0${config["browserstack-access-key"]}`,
)
.digest("hex");
}

function remember(key: string, entry: Lookup): void {
lookups.delete(key);
lookups.set(key, entry);
if (lookups.size > MAX_ENTRIES) {
lookups.delete(lookups.keys().next().value!);
}
}

Comment thread
SavioBS629 marked this conversation as resolved.
Resolved
/** Reads `user.grr.region` from the token; an account with no region is a US account. */
export function regionFromToken(token: string): AccountRegion {
const payload = JSON.parse(
Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"),
);
const raw = payload?.user?.grr?.region;
if (raw === undefined || raw === null || raw === "") return "us";
const region = String(raw).toLowerCase();
return (GRR_REGIONS as readonly string[]).includes(region)
? (region as GrrRegion)
: "unknown";
}

/** The account's region from BrowserStack auth, or null when auth names none we know. */
async function lookUp(config: BrowserStackConfig): Promise<GrrRegion | null> {
try {
const token = await mintCentralToken({
url: AUTH_TOKEN_URL,
scope: SCOPE,
credentials: {
username: config["browserstack-username"],
accessKey: config["browserstack-access-key"],
},
transport: fetchTokenTransport(LOOKUP_TIMEOUT_MS),
refreshSkewMs: 0,
cacheEnabled: false,
});
const region = regionFromToken(token);
return region === "unknown" ? null : region;
} catch (error) {
logger.warn(
`Could not look up the account's region: ${(error as Error)?.message}`,
);
return null;
}
}

/**
* The account's region, looked up once per account and refreshed after REGION_TTL_MS.
* Returned directly once known, so a known region costs a call no wait; undefined without
* credentials. A refresh keeps answering the last known region, and keeps it if the
* refresh fails. With no region known, a failure answers "unknown" for RETRY_AFTER_MS,
* and the next call after that looks up again.
*/
export function accountRegion(
config: BrowserStackConfig | undefined,
): AccountRegion | undefined | Promise<AccountRegion> {
if (
!config?.["browserstack-username"] ||
!config?.["browserstack-access-key"]
) {
return undefined;
}
const now = Date.now();
const key = keyFor(config);
const lookup = lookups.get(key);
if (lookup?.settled && now - lookup.settledAt! < REGION_TTL_MS) {
return lookup.settled;
}
if (lookup?.pending) return lookup.settled ?? lookup.pending;
if (
lookup?.failedAt !== undefined &&
now - lookup.failedAt < RETRY_AFTER_MS
) {
return lookup.settled ?? "unknown";
}

const entry: Lookup = {
settled: lookup?.settled,
settledAt: lookup?.settledAt,
};
entry.pending = lookUp(config).then((region) => {
entry.pending = undefined;
if (region === null) {
entry.failedAt = Date.now();
return entry.settled ?? "unknown";
}
entry.settled = region;
entry.settledAt = Date.now();
entry.failedAt = undefined;
return region;
});
remember(key, entry);
return entry.settled ?? entry.pending;
}
4 changes: 3 additions & 1 deletion src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { getBrowserStackAuth } from "./get-auth.js";
import { BrowserStackConfig } from "../lib/types.js";
import { apiClient } from "./apiClient.js";
import { getRegionalHost } from "./regional-host.js";

export async function getLatestO11YBuildInfo(
buildName: string,
projectName: string,
config: BrowserStackConfig,
) {
const buildsUrl = `https://api-observability.browserstack.com/ext/v1/builds/latest?build_name=${encodeURIComponent(
const host = getRegionalHost("observabilityApi");
const buildsUrl = `${host}/ext/v1/builds/latest?build_name=${encodeURIComponent(
buildName,
)}&project_name=${encodeURIComponent(projectName)}`;

Expand Down
33 changes: 33 additions & 0 deletions src/lib/region-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { AsyncLocalStorage } from "node:async_hooks";

export const GRR_REGIONS = ["us", "eu", "in"] as const;
export type GrrRegion = (typeof GRR_REGIONS)[number];

/** The account's region from BrowserStack auth, or "unknown" when the lookup failed. */
export type AccountRegion = GrrRegion | "unknown";

interface RegionScope {
account: AccountRegion | undefined;
}

// Per call, not per process: concurrent calls for different accounts each see their own.
const scope = new AsyncLocalStorage<RegionScope>();

export function withAccountRegion<T>(
account: AccountRegion | undefined,
fn: () => T,
): T {
return scope.run({ account }, fn);
}

/** The scope set for the current call, or undefined outside one. */
export function currentRegionScope(): RegionScope | undefined {
return scope.getStore();
}

/** The account's region, or undefined when it is not known. */
export function regionOf(
account: AccountRegion | undefined,
Comment thread
SavioBS629 marked this conversation as resolved.
Resolved
): GrrRegion | undefined {
return account && account !== "unknown" ? account : undefined;
}
150 changes: 150 additions & 0 deletions src/lib/regional-host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import appConfig from "../config.js";
import { SessionType } from "./constants.js";
import { currentRegionScope, GrrRegion, regionOf } from "./region-context.js";

/** Regions with their own hosts here. Other regions use the default (US) hosts. */
type RoutedRegion = "us" | "eu";

type Hosts = Record<RoutedRegion, string>;

const AUTOMATE_API: Hosts = {
us: "https://api.browserstack.com",
eu: "https://api-eu-only.browserstack.com",
};

const HOSTS = {
testManagement: {
us: "https://test-management.browserstack.com",
eu: "https://test-management-eu.browserstack.com",
},
automateApi: AUTOMATE_API,
appAutomateApi: AUTOMATE_API,
appAutomateCloud: {
us: "https://api-cloud.browserstack.com",
eu: AUTOMATE_API.eu,
},
appAutomateHub: {
us: "hub.browserstack.com",
eu: "hub-eu-only.browserstack.com",
},
automateHubUrl: {
us: "https://hub.browserstack.com",
eu: "https://hub-eu-only.browserstack.com",
},
appAutomateHubUrl: {
us: "https://hub-cloud.browserstack.com",
eu: "https://hub-eu-only.browserstack.com",
},
accessibilityApi: {
us: "https://api-accessibility.browserstack.com",
eu: "https://api-accessibility-eu.browserstack.com",
},
accessibilityDashboard: {
us: "https://accessibility.browserstack.com",
eu: "https://accessibility-eu.browserstack.com",
},
appAccessibilityApi: {
us: "https://api-app-accessibility.browserstack.com",
eu: "https://api-app-accessibility-eu.browserstack.com",
},
appAccessibilityDashboard: {
us: "https://app-accessibility.browserstack.com",
eu: "https://app-accessibility-eu.browserstack.com",
},
observabilityApi: {
us: "https://api-observability.browserstack.com",
eu: "https://api-observability-eu.browserstack.com",
},
automationApi: {
us: "https://api-automation.browserstack.com",
eu: "https://api-automation-eu.browserstack.com",
},
} as const satisfies Record<string, Hosts>;

export type RegionalHostKind = keyof typeof HOSTS;

export function hostFor(kind: RegionalHostKind, region: RoutedRegion): string {
return HOSTS[kind][region];
}

/** True when `hostname` is `kind`'s host in any region, so a link from any region resolves. */
export function isRegionalHostname(
kind: RegionalHostKind,
hostname: string,
): boolean {
return Object.values(HOSTS[kind]).some(
(host) => new URL(host).hostname === hostname,
);
}

/** This call's account region; undefined outside a call or when the lookup failed. */
export function currentRegion(): GrrRegion | undefined {
return regionOf(currentRegionScope()?.account);
}

export const REGION_UNKNOWN_MESSAGE =
"Could not confirm your BrowserStack account's region. Please retry in a few seconds.";

/** Thrown instead of guessing a host when the account's region could not be looked up. */
export class AccountRegionUnknownError extends Error {
constructor() {
super(REGION_UNKNOWN_MESSAGE);
this.name = "AccountRegionUnknownError";
}
}

/** Throws when this call's account region could not be looked up. */
export function assertRegionKnown(): void {
if (currentRegionScope()?.account === "unknown") {
throw new AccountRegionUnknownError();
}
}

/** The region whose hosts this call uses; throws when the region is unknown. */
export function routedRegion(): RoutedRegion {
assertRegionKnown();
return currentRegion() === "eu" ? "eu" : "us";
Comment thread
SavioBS629 marked this conversation as resolved.
Resolved
}

/** True when this call goes to regional hosts rather than the defaults. */
export function usesRegionalHosts(): boolean {
return currentRegion() === "eu";
}

/** REST host for a session's product: App Automate or Automate. */
export function sessionApiHost(sessionType: string): string {
return getRegionalHost(
sessionType === SessionType.AppAutomate ? "appAutomateApi" : "automateApi",
);
}

/**
* An env var's host when it points somewhere other than `kind`'s US default, without a
* trailing slash; undefined when it is unset or names the default in any spelling.
*/
export function hostOverride(
url: string | undefined,
kind: RegionalHostKind,
): string | undefined {
const value = url?.trim().replace(/\/+$/, "");
if (!value) return undefined;
try {
if (new URL(value).origin === new URL(HOSTS[kind].us).origin) {
return undefined;
}
} catch {
// Not a URL: pass it through as given.
}
return value;
}

export function getRegionalHost(kind: RegionalHostKind): string {
if (kind === "automationApi") {
const override = hostOverride(
appConfig.BROWSERSTACK_AUTOMATION_BASE_URL,
kind,
);
if (override) return override;
}
return hostFor(kind, routedRegion());
}
8 changes: 8 additions & 0 deletions src/lib/tm-base-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import logger from "../logger.js";
import { BrowserStackConfig } from "./types.js";
import { getBrowserStackAuth } from "./get-auth.js";
import appConfig from "../config.js";
import { getRegionalHost, usesRegionalHosts } from "./regional-host.js";

// Probe order when the account has no regional hosts here.
const TM_BASE_URLS = [
"https://test-management.browserstack.com",
"https://test-management-eu.browserstack.com",
Expand All @@ -15,6 +17,12 @@ let cachedBaseUrl: string | null = null;
export async function getTMBaseURL(
config: BrowserStackConfig,
): Promise<string> {
// A routed region is used as given, like every other product. Any other account keeps
// the probe below.
if (usesRegionalHosts()) {
return getRegionalHost("testManagement");
}

// Skip the module-level cache in remote (multi-tenant) mode: it is process-shared,
// so the first user's region would be served to every subsequent user — breaking
// requests for users on a different region's BrowserStack account.
Expand Down
26 changes: 26 additions & 0 deletions src/lib/tool-latency.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ClientInfo, withToolCall } from "./instrumentation.js";
import { withFeedbackHint } from "./feedback-hint.js";
import { accountRegion } from "./account-region.js";
import { withAccountRegion } from "./region-context.js";
import { BrowserStackConfig } from "./types.js";

const WRAPPED = Symbol.for("browserstack.mcp.latencyWrapped");
const REGION_BOUND = Symbol.for("browserstack.mcp.regionBound");

type AnyHandler = (...args: unknown[]) => unknown;

Expand All @@ -27,3 +31,25 @@ export function instrumentToolLatency(
(tool as { handler: unknown }).handler = wrapped;
}
}

/** Runs every handler in the account's region, looked up once for `config`. Idempotent. */
export function bindAccountRegion(
tools: Record<string, RegisteredTool>,
config: BrowserStackConfig,
): void {
for (const tool of Object.values(tools)) {
const inner = tool.handler as unknown;
if (typeof inner !== "function") continue;
if ((inner as AnyHandler & { [REGION_BOUND]?: true })[REGION_BOUND])
continue;

const bound: AnyHandler & { [REGION_BOUND]?: true } = async (
...args: unknown[]
) => {
const account = await accountRegion(config);
return withAccountRegion(account, () => (inner as AnyHandler)(...args));
};
bound[REGION_BOUND] = true;
(tool as { handler: unknown }).handler = bound;
}
}
Loading