Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
104 changes: 94 additions & 10 deletions src/lib/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ const { HttpsProxyAgent } = httpsProxyAgentPkg;
import * as https from "https";
import * as fs from "fs";
import config from "../config.js";
import { isDataUrlPayloadTooLarge } from "../lib/utils.js";

type RequestOptions = {
url: string;
headers?: Record<string, string>;
params?: Record<string, string | number>;
body?: any;
timeout?: number;
raise_error?: boolean; // default: true
};

Expand Down Expand Up @@ -99,11 +101,53 @@ class ApiClient {
return getAxiosAgent();
}

private validateUrl(url: string, options?: AxiosRequestConfig) {
try {
const parsedUrl = new URL(url);

// Default safe limits
const maxContentLength = options?.maxContentLength ?? 20 * 1024 * 1024; // 20MB
const maxBodyLength = options?.maxBodyLength ?? 20 * 1024 * 1024; // 20MB
const maxUrlLength = 8000; // cutoff for URLs

// Check overall URL length
if (url.length > maxUrlLength) {
throw new Error(
`URL length exceeds maxUrlLength (${maxUrlLength} chars)`,
);
}

if (parsedUrl.protocol === "data:") {
// Either reject completely OR check payload size
if (isDataUrlPayloadTooLarge(url, maxContentLength)) {
throw new Error("data: URI payload too large or invalid");
}
} else if (!["http:", "https:"].includes(parsedUrl.protocol)) {
throw new Error(`Unsupported URL scheme: ${parsedUrl.protocol}`);
}

if (
options?.data &&
Buffer.byteLength(JSON.stringify(options.data), "utf8") > maxBodyLength
) {
throw new Error(
`Request body exceeds maxBodyLength (${maxBodyLength} bytes)`,
);
}
} catch (error: any) {
throw new Error(`Invalid URL: ${error.message}`);
}
}

private async requestWrapper<T>(
fn: (agent: AxiosRequestConfig["httpsAgent"]) => Promise<AxiosResponse<T>>,
url: string,
config?: AxiosRequestConfig,
raise_error: boolean = true,
): Promise<ApiResponse<T>> {
try {
this.validateUrl(url, config);

const res = await fn(this.axiosAgent);
return new ApiResponse<T>(res);
} catch (error: any) {
Expand All @@ -118,11 +162,19 @@ class ApiClient {
url,
headers,
params,
timeout,
raise_error = true,
}: RequestOptions): Promise<ApiResponse<T>> {
const config: AxiosRequestConfig = {
headers,
params,
timeout,
httpsAgent: this.axiosAgent,
};
return this.requestWrapper<T>(
(agent) =>
this.instance.get<T>(url, { headers, params, httpsAgent: agent }),
() => this.instance.get<T>(url, config),
url,
config,
raise_error,
);
}
Expand All @@ -131,11 +183,19 @@ class ApiClient {
url,
headers,
body,
timeout,
raise_error = true,
}: RequestOptions): Promise<ApiResponse<T>> {
const config: AxiosRequestConfig = {
headers,
timeout,
httpsAgent: this.axiosAgent,
data: body,
};
return this.requestWrapper<T>(
(agent) =>
this.instance.post<T>(url, body, { headers, httpsAgent: agent }),
() => this.instance.post<T>(url, config.data, config),
url,
config,
raise_error,
);
}
Expand All @@ -144,11 +204,19 @@ class ApiClient {
url,
headers,
body,
timeout,
raise_error = true,
}: RequestOptions): Promise<ApiResponse<T>> {
const config: AxiosRequestConfig = {
headers,
timeout,
httpsAgent: this.axiosAgent,
data: body,
};
return this.requestWrapper<T>(
(agent) =>
this.instance.put<T>(url, body, { headers, httpsAgent: agent }),
() => this.instance.put<T>(url, config.data, config),
url,
config,
raise_error,
);
}
Expand All @@ -157,11 +225,19 @@ class ApiClient {
url,
headers,
body,
timeout,
raise_error = true,
}: RequestOptions): Promise<ApiResponse<T>> {
const config: AxiosRequestConfig = {
headers,
timeout,
httpsAgent: this.axiosAgent,
data: body,
};
return this.requestWrapper<T>(
(agent) =>
this.instance.patch<T>(url, body, { headers, httpsAgent: agent }),
() => this.instance.patch<T>(url, config.data, config),
url,
config,
raise_error,
);
}
Expand All @@ -170,11 +246,19 @@ class ApiClient {
url,
headers,
params,
timeout,
raise_error = true,
}: RequestOptions): Promise<ApiResponse<T>> {
const config: AxiosRequestConfig = {
headers,
params,
timeout,
httpsAgent: this.axiosAgent,
};
return this.requestWrapper<T>(
(agent) =>
this.instance.delete<T>(url, { headers, params, httpsAgent: agent }),
() => this.instance.delete<T>(url, config),
url,
config,
raise_error,
);
}
Expand Down
9 changes: 6 additions & 3 deletions src/lib/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getBrowserStackAuth } from "./get-auth.js";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const packageJson = require("../../package.json");
import axios from "axios";
import { apiClient } from "./apiClient.js";
import globalConfig from "../config.js";

interface MCPEventPayload {
Expand Down Expand Up @@ -63,13 +63,16 @@ export function trackMCP(
authHeader = `Basic ${Buffer.from(authString).toString("base64")}`;
}

axios
.post(instrumentationEndpoint, event, {
apiClient
.post({
url: instrumentationEndpoint,
body: event,
headers: {
"Content-Type": "application/json",
...(authHeader ? { Authorization: authHeader } : {}),
},
timeout: 2000,
raise_error: false,
})
.catch(() => {});
}
24 changes: 24 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,27 @@ export function handleMCPError(
`Failed to ${readableToolName}: ${errorMessage}. Please open an issue on GitHub if the problem persists`,
);
}

export function isDataUrlPayloadTooLarge(
dataUrl: string,
maxBytes: number,
): boolean {
const commaIndex = dataUrl.indexOf(",");
if (commaIndex === -1) return true; // malformed
const meta = dataUrl.slice(0, commaIndex);
const payload = dataUrl.slice(commaIndex + 1);

const isBase64 = /;base64$/i.test(meta);
if (!isBase64) {
try {
const decoded = decodeURIComponent(payload);
return Buffer.byteLength(decoded, "utf8") > maxBytes;
} catch {
return true;
}
}

const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
const decodedBytes = Math.floor((payload.length * 3) / 4) - padding;
return decodedBytes > maxBytes;
}