Skip to content

Commit 7253d65

Browse files
authored
Merge pull request #97 from ruturaj-browserstack/mcp-as-library
feat : load config from local context instead of global variables
2 parents c621255 + b20e36e commit 7253d65

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+1704
-858
lines changed

src/config.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,18 @@ for (const key of BROWSERSTACK_LOCAL_OPTION_KEYS) {
3636
*/
3737
export class Config {
3838
constructor(
39-
public readonly browserstackUsername: string,
40-
public readonly browserstackAccessKey: string,
4139
public readonly DEV_MODE: boolean,
4240
public readonly browserstackLocalOptions: Record<string, any>,
4341
public readonly USE_OWN_LOCAL_BINARY_PROCESS: boolean,
42+
public readonly REMOTE_MCP: boolean,
4443
) {}
4544
}
4645

4746
const config = new Config(
48-
process.env.BROWSERSTACK_USERNAME!,
49-
process.env.BROWSERSTACK_ACCESS_KEY!,
5047
process.env.DEV_MODE === "true",
5148
browserstackLocalOptions,
5249
process.env.USE_OWN_LOCAL_BINARY_PROCESS === "true",
50+
process.env.REMOTE_MCP === "true",
5351
);
5452

5553
export default config;

src/index.ts

Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,43 @@
11
#!/usr/bin/env node
22

3-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
43
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
54
import { createRequire } from "module";
65
const require = createRequire(import.meta.url);
76
const packageJson = require("../package.json");
87
import "dotenv/config";
98
import logger from "./logger.js";
10-
import addSDKTools from "./tools/bstack-sdk.js";
11-
import addAppLiveTools from "./tools/applive.js";
12-
import addBrowserLiveTools from "./tools/live.js";
13-
import addAccessibilityTools from "./tools/accessibility.js";
14-
import addTestManagementTools from "./tools/testmanagement.js";
15-
import addAppAutomationTools from "./tools/appautomate.js";
16-
import addFailureLogsTools from "./tools/getFailureLogs.js";
17-
import addAutomateTools from "./tools/automate.js";
18-
import addSelfHealTools from "./tools/selfheal.js";
19-
import { setupOnInitialized } from "./oninitialized.js";
20-
21-
function registerTools(server: McpServer) {
22-
addAccessibilityTools(server);
23-
addSDKTools(server);
24-
addAppLiveTools(server);
25-
addBrowserLiveTools(server);
26-
addTestManagementTools(server);
27-
addAppAutomationTools(server);
28-
addFailureLogsTools(server);
29-
addAutomateTools(server);
30-
addSelfHealTools(server);
31-
}
32-
33-
// Create an MCP server
34-
const server: McpServer = new McpServer({
35-
name: "BrowserStack MCP Server",
36-
version: packageJson.version,
37-
});
38-
39-
setupOnInitialized(server);
40-
41-
registerTools(server);
9+
import { createMcpServer } from "./server-factory.js";
4210

4311
async function main() {
4412
logger.info(
4513
"Launching BrowserStack MCP server, version %s",
4614
packageJson.version,
4715
);
4816

49-
// Start receiving messages on stdin and sending messages on stdout
17+
const remoteMCP = process.env.REMOTE_MCP === "true";
18+
if (remoteMCP) {
19+
logger.info("Running in remote MCP mode");
20+
return;
21+
}
22+
23+
const username = process.env.BROWSERSTACK_USERNAME;
24+
const accessKey = process.env.BROWSERSTACK_ACCESS_KEY;
25+
26+
if (!username) {
27+
throw new Error("BROWSERSTACK_USERNAME environment variable is required");
28+
}
29+
30+
if (!accessKey) {
31+
throw new Error("BROWSERSTACK_ACCESS_KEY environment variable is required");
32+
}
33+
5034
const transport = new StdioServerTransport();
35+
36+
const server = createMcpServer({
37+
"browserstack-username": username,
38+
"browserstack-access-key": accessKey,
39+
});
40+
5141
await server.connect(transport);
5242
}
5343

@@ -57,3 +47,6 @@ main().catch(console.error);
5747
process.on("exit", () => {
5848
logger.flush();
5949
});
50+
51+
export { createMcpServer } from "./server-factory.js";
52+
export { setLogger } from "./logger.js";

src/lib/api.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
1-
import config from "../config.js";
1+
import { getBrowserStackAuth } from "./get-auth.js";
2+
import { BrowserStackConfig } from "../lib/types.js";
3+
import { apiClient } from "./apiClient.js";
24

35
export async function getLatestO11YBuildInfo(
46
buildName: string,
57
projectName: string,
8+
config: BrowserStackConfig,
69
) {
710
const buildsUrl = `https://api-observability.browserstack.com/ext/v1/builds/latest?build_name=${encodeURIComponent(
811
buildName,
912
)}&project_name=${encodeURIComponent(projectName)}`;
1013

11-
const buildsResponse = await fetch(buildsUrl, {
14+
const authString = getBrowserStackAuth(config);
15+
const auth = Buffer.from(authString).toString("base64");
16+
17+
const buildsResponse = await apiClient.get({
18+
url: buildsUrl,
1219
headers: {
13-
Authorization: `Basic ${Buffer.from(
14-
`${config.browserstackUsername}:${config.browserstackAccessKey}`,
15-
).toString("base64")}`,
20+
Authorization: `Basic ${auth}`,
1621
},
22+
raise_error: false,
1723
});
1824

1925
if (!buildsResponse.ok) {
@@ -25,5 +31,5 @@ export async function getLatestO11YBuildInfo(
2531
throw new Error(`Failed to fetch builds: ${buildsResponse.statusText}`);
2632
}
2733

28-
return buildsResponse.json();
34+
return buildsResponse;
2935
}

src/lib/apiClient.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
2+
3+
type RequestOptions = {
4+
url: string;
5+
headers?: Record<string, string>;
6+
params?: Record<string, string | number>;
7+
body?: any;
8+
raise_error?: boolean; // default: true
9+
};
10+
11+
class ApiResponse<T = any> {
12+
private _response: AxiosResponse<T>;
13+
14+
constructor(response: AxiosResponse<T>) {
15+
this._response = response;
16+
}
17+
18+
get data(): T {
19+
return this._response.data;
20+
}
21+
22+
get status(): number {
23+
return this._response.status;
24+
}
25+
26+
get statusText(): string {
27+
return this._response.statusText;
28+
}
29+
30+
get headers(): Record<string, string> {
31+
const raw = this._response.headers;
32+
const sanitized: Record<string, string> = {};
33+
34+
for (const key in raw) {
35+
const value = raw[key];
36+
if (typeof value === "string") {
37+
sanitized[key] = value;
38+
}
39+
}
40+
41+
return sanitized;
42+
}
43+
44+
get config(): AxiosRequestConfig {
45+
return this._response.config;
46+
}
47+
48+
get url(): string | undefined {
49+
return this._response.config.url;
50+
}
51+
52+
get ok(): boolean {
53+
return this._response.status >= 200 && this._response.status < 300;
54+
}
55+
}
56+
57+
class ApiClient {
58+
private instance = axios.create();
59+
60+
private async requestWrapper<T>(
61+
fn: () => Promise<AxiosResponse<T>>,
62+
raise_error: boolean = true,
63+
): Promise<ApiResponse<T>> {
64+
try {
65+
const res = await fn();
66+
return new ApiResponse<T>(res);
67+
} catch (error: any) {
68+
if (error.response && !raise_error) {
69+
return new ApiResponse<T>(error.response);
70+
}
71+
throw error;
72+
}
73+
}
74+
75+
async get<T = any>({
76+
url,
77+
headers,
78+
params,
79+
raise_error = true,
80+
}: RequestOptions): Promise<ApiResponse<T>> {
81+
return this.requestWrapper<T>(
82+
() => this.instance.get<T>(url, { headers, params }),
83+
raise_error,
84+
);
85+
}
86+
87+
async post<T = any>({
88+
url,
89+
headers,
90+
body,
91+
raise_error = true,
92+
}: RequestOptions): Promise<ApiResponse<T>> {
93+
return this.requestWrapper<T>(
94+
() => this.instance.post<T>(url, body, { headers }),
95+
raise_error,
96+
);
97+
}
98+
99+
async put<T = any>({
100+
url,
101+
headers,
102+
body,
103+
raise_error = true,
104+
}: RequestOptions): Promise<ApiResponse<T>> {
105+
return this.requestWrapper<T>(
106+
() => this.instance.put<T>(url, body, { headers }),
107+
raise_error,
108+
);
109+
}
110+
111+
async patch<T = any>({
112+
url,
113+
headers,
114+
body,
115+
raise_error = true,
116+
}: RequestOptions): Promise<ApiResponse<T>> {
117+
return this.requestWrapper<T>(
118+
() => this.instance.patch<T>(url, body, { headers }),
119+
raise_error,
120+
);
121+
}
122+
123+
async delete<T = any>({
124+
url,
125+
headers,
126+
params,
127+
raise_error = true,
128+
}: RequestOptions): Promise<ApiResponse<T>> {
129+
return this.requestWrapper<T>(
130+
() => this.instance.delete<T>(url, { headers, params }),
131+
raise_error,
132+
);
133+
}
134+
}
135+
136+
export const apiClient = new ApiClient();
137+
export type { ApiResponse, RequestOptions };

src/lib/device-cache.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fs from "fs";
22
import os from "os";
33
import path from "path";
4+
import { apiClient } from "./apiClient.js";
45

56
const CACHE_DIR = path.join(os.homedir(), ".browserstack", "combined_cache");
67
const CACHE_FILE = path.join(CACHE_DIR, "data.json");
@@ -48,18 +49,16 @@ export async function getDevicesAndBrowsers(
4849
}
4950
}
5051

51-
const liveRes = await fetch(URLS[type]);
52+
const liveRes = await apiClient.get({ url: URLS[type], raise_error: false });
5253

5354
if (!liveRes.ok) {
5455
throw new Error(
5556
`Failed to fetch configuration from BrowserStack : ${type}=${liveRes.statusText}`,
5657
);
5758
}
5859

59-
const data = await liveRes.json();
60-
6160
cache = {
62-
[type]: data,
61+
[type]: liveRes.data,
6362
};
6463
fs.writeFileSync(CACHE_FILE, JSON.stringify(cache), "utf8");
6564

src/lib/get-auth.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { BrowserStackConfig } from "../lib/types.js";
2+
3+
export function getBrowserStackAuth(config: BrowserStackConfig): string {
4+
const username = config["browserstack-username"];
5+
const accessKey = config["browserstack-access-key"];
6+
if (!username || !accessKey) {
7+
throw new Error("BrowserStack credentials not set on server.authHeaders");
8+
}
9+
return `${username}:${accessKey}`;
10+
}

src/lib/instrumentation.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import logger from "../logger.js";
2-
import config from "../config.js";
2+
import { getBrowserStackAuth } from "./get-auth.js";
33
import { createRequire } from "module";
44
const require = createRequire(import.meta.url);
55
const packageJson = require("../../package.json");
@@ -21,12 +21,8 @@ export function trackMCP(
2121
toolName: string,
2222
clientInfo: { name?: string; version?: string },
2323
error?: unknown,
24+
config?: any,
2425
): void {
25-
if (config.DEV_MODE) {
26-
logger.info("Tracking MCP is disabled in dev mode");
27-
return;
28-
}
29-
3026
const instrumentationEndpoint = "https://api.browserstack.com/sdk/v1/event";
3127
const isSuccess = !error;
3228
const mcpClient = clientInfo?.name || "unknown";
@@ -58,13 +54,17 @@ export function trackMCP(
5854
error instanceof Error ? error.constructor.name : "Unknown";
5955
}
6056

57+
let authHeader = undefined;
58+
if (config) {
59+
const authString = getBrowserStackAuth(config);
60+
authHeader = `Basic ${Buffer.from(authString).toString("base64")}`;
61+
}
62+
6163
axios
6264
.post(instrumentationEndpoint, event, {
6365
headers: {
6466
"Content-Type": "application/json",
65-
Authorization: `Basic ${Buffer.from(
66-
`${config.browserstackUsername}:${config.browserstackAccessKey}`,
67-
).toString("base64")}`,
67+
...(authHeader ? { Authorization: authHeader } : {}),
6868
},
6969
timeout: 2000,
7070
})

src/lib/local.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ export async function killExistingBrowserStackLocalProcesses() {
7474
}
7575

7676
export async function ensureLocalBinarySetup(
77+
username: string,
78+
password: string,
7779
localIdentifier?: string,
7880
): Promise<void> {
7981
logger.info(
@@ -104,8 +106,8 @@ export async function ensureLocalBinarySetup(
104106
// Use a single options object from config and extend with required fields
105107
const bsLocalArgs: Record<string, any> = {
106108
...(config.browserstackLocalOptions || {}),
107-
key: config.browserstackAccessKey,
108-
username: config.browserstackUsername,
109+
key: password,
110+
username,
109111
};
110112

111113
if (localIdentifier) {

src/lib/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export type BrowserStackConfig = {
2+
"browserstack-username": string;
3+
"browserstack-access-key": string;
4+
};

0 commit comments

Comments
 (0)