Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
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
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ async function main() {

const transport = new StdioServerTransport();

const mcpServer = new BrowserStackMcpServer({
const mcpServer = await BrowserStackMcpServer.create({
"browserstack-username": username,
"browserstack-access-key": accessKey,
});
Expand Down
183 changes: 183 additions & 0 deletions src/lib/account-region.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { createHash } from "node:crypto";
import logger from "../logger.js";
import { apiClient } from "./apiClient.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;
/** How long a region found by probing is reused, so BrowserStack auth is asked again soon. */
export const PROBED_TTL_MS = 5 * 60_000;

// Tried when BrowserStack auth can't answer: the first host that accepts the account's
// credentials is its region. Regional hosts first, so a GRR account is found there.
const PROBE_HOSTS: ReadonlyArray<[GrrRegion, string]> = [
["eu", "https://test-management-eu.browserstack.com"],
["in", "https://test-management-in.browserstack.com"],
["us", "https://test-management.browserstack.com"],
];
const MAX_ENTRIES = 1_000;

interface Lookup {
settled?: AccountRegion;
settledAt?: number;
ttl?: 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 could not answer. */
async function fromToken(
config: BrowserStackConfig,
): Promise<AccountRegion | 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,
});
return regionFromToken(token);
} catch (error) {
logger.warn(
`Could not look up the account's region: ${(error as Error)?.message}`,
);
return null;
}
}

/** The region whose Test Management host accepts the account, or null when none does. */
async function probe(config: BrowserStackConfig): Promise<GrrRegion | null> {
const authorization =
"Basic " +
Buffer.from(
`${config["browserstack-username"]}:${config["browserstack-access-key"]}`,
).toString("base64");
for (const [region, host] of PROBE_HOSTS) {
try {
const res = await apiClient.get({
url: `${host}/api/v2/projects/`,
headers: { Authorization: authorization },
timeout: LOOKUP_TIMEOUT_MS,
raise_error: false,
});
if (res.ok) return region;
} catch (error) {
logger.debug(`Region probe failed for ${host}: ${error}`);
}
}
return null;
}

interface Answer {
region: AccountRegion;
ttl: number;
}

/** BrowserStack auth's answer, else a probe's; null when neither can tell. */
async function lookUp(config: BrowserStackConfig): Promise<Answer | null> {
const region = await fromToken(config);
if (region !== null && region !== "unknown") {
return { region, ttl: REGION_TTL_MS };
}
const probed = await probe(config);
if (probed) return { region: probed, ttl: PROBED_TTL_MS };
return region === null ? null : { region, ttl: REGION_TTL_MS };
}

/**
* The account's region, looked up once per account and refreshed after REGION_TTL_MS
* (PROBED_TTL_MS when it was found by probing).
* Returned directly once known so callers that cannot wait (telemetry) stay synchronous;
* undefined without credentials. When auth can't answer, the account's region is found by
* probing the Test Management hosts. 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! < lookup.ttl!) {
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,
ttl: lookup?.ttl,
};
entry.pending = lookUp(config).then((answer) => {
entry.pending = undefined;
if (answer === null) {
entry.failedAt = Date.now();
return entry.settled ?? "unknown";
}
entry.settled = answer.region;
entry.settledAt = Date.now();
entry.ttl = answer.ttl;
entry.failedAt = undefined;
return answer.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
21 changes: 20 additions & 1 deletion src/lib/feedback-hint.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
import {
currentRegionScope,
isRestrictedRegion,
isTelemetryOff,
} from "./region-context.js";

/** Whether this call may send feedback; not while the account's region is unknown. */
export function feedbackEnabled(): boolean {
return !isRestrictedRegion(currentRegionScope()?.account);
}

/**
* Whether to list submitFeedback. An unknown region may be looked up later in the
* session, so it stays listed and each call decides.
*/
export function feedbackListed(): boolean {
return !isTelemetryOff(currentRegionScope()?.account);
}

export const FEEDBACK_INSTRUCTION =
"If a BrowserStack tool cannot do what the user needs, call submitFeedback before giving up or working around it.";

Expand Down Expand Up @@ -25,7 +44,7 @@ export const NO_HINT_TOOLS: ReadonlySet<string> = new Set([
]);

export function withFeedbackHint<T>(toolName: string, result: T): T {
if (NO_HINT_TOOLS.has(toolName)) return result;
if (!feedbackEnabled() || NO_HINT_TOOLS.has(toolName)) return result;
const r = result as { isError?: unknown; content?: unknown } | null;
if (typeof r !== "object" || r === null || r.isError !== true) return result;
if (!Array.isArray(r.content) || SELF_FIXABLE.test(textOf(r))) return result;
Expand Down
63 changes: 63 additions & 0 deletions src/lib/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ const packageJson = require("../../package.json");
import { apiClient } from "./apiClient.js";
import globalConfig from "../config.js";
import { redact } from "../tools/capability-registry/redact.js";
import { accountRegion } from "./account-region.js";
import {
AccountRegion,
currentRegionScope,
isRestrictedRegion,
isTelemetryOff,
} from "./region-context.js";

const INSTRUMENTATION_ENDPOINT = "https://api.browserstack.com/sdk/v1/event";

Expand Down Expand Up @@ -64,7 +71,63 @@ function errorProperties(error: unknown) {
};
}

const NON_US_REGION_FIELDS = new Set([
"mcp_version",
"tool_name",
"mcp_client",
"node_version",
"is_remote",
"success",
"duration_ms",
"outcome",
"error_type",
"refusal_reason",
"product",
"entity",
"capability",
"capability_method",
"capability_path",
"capability_mode",
"results_returned",
"total_matched",
"truncated",
"weak_match",
"coverage",
"upstream_ok",
"upstream_status",
]);

function forDataRegion(
props: MCPEventPayload["event_properties"],
account: AccountRegion | undefined,
): MCPEventPayload["event_properties"] {
if (!isRestrictedRegion(account)) return props;
return Object.fromEntries(
Object.entries(props).filter(([key]) => NON_US_REGION_FIELDS.has(key)),
) as MCPEventPayload["event_properties"];
}

function sendEvent(event: MCPEventPayload, config?: any): void {
// Inside a tool call the region is already known; outside one, wait for the lookup.
const inCall = currentRegionScope();
const account = inCall ? inCall.account : accountRegion(config);
if (account instanceof Promise) {
account.then((region) => post(event, config, region)).catch(() => {});
return;
}
post(event, config, account);
}

function post(
event: MCPEventPayload,
config: any,
account: AccountRegion | undefined,
): void {
if (isTelemetryOff(account)) return;
event = {
...event,
event_properties: forDataRegion(event.event_properties, account),
};
let authHeader: string | undefined;
if (config) {
try {
Expand Down
46 changes: 46 additions & 0 deletions src/lib/region-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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;
}

/** True for an EU or IN account: no telemetry is sent for it. */
export function isTelemetryOff(account: AccountRegion | undefined): boolean {
const region = regionOf(account);
return region !== undefined && region !== "us";
}

/** True when telemetry must be limited to usage fields; an unknown account counts. */
export function isRestrictedRegion(
account: AccountRegion | undefined,
): boolean {
return account === "unknown" || (account !== undefined && account !== "us");
}
Loading