@smartbear/mcp
Version:
MCP server for interacting SmartBear Products
192 lines (191 loc) • 6.98 kB
JavaScript
import { ToolError } from "../../../common/types.js";
// Utility to pick only allowed fields from an object
export function pickFields(obj, keys) {
const result = {};
if (!obj)
return result;
for (const key of keys) {
if (key in obj) {
result[key] = obj[key];
}
}
return result;
}
// Utility to pick only allowed fields from an array of objects
export function pickFieldsFromArray(arr, keys) {
return arr.map((obj) => pickFields(obj, keys));
}
// Utility to extract next URL path from Link header
export function getNextUrlPathFromHeader(headers, basePath) {
if (!headers)
return null;
const link = headers.get("link") || headers.get("Link");
if (!link)
return null;
const match = link.match(/<([^>]+)>;\s*rel="next"/)?.[1];
if (!match)
return null;
return match.replace(basePath, "");
}
// Utility to extract total count from headers
function getTotalCountFromHeader(headers) {
if (!headers)
return null;
const totalCount = headers.get("X-Total-Count");
if (!totalCount)
return null;
const parsed = parseInt(totalCount, 10);
return Number.isNaN(parsed) ? null : parsed;
}
// Utility to recursively convert object keys from snake_case to camelCase
function convertKeysToCamelCase(obj) {
if (obj === null || obj === undefined) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(convertKeysToCamelCase);
}
if (typeof obj === "object" && obj.constructor === Object) {
const converted = {};
for (const [key, value] of Object.entries(obj)) {
const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
converted[camelKey] = convertKeysToCamelCase(value);
}
return converted;
}
return obj;
}
// Ensure URL is absolute
// The MCP tools exposed use only the path for pagination
// For making requests, we need to ensure the URL is absolute
export function ensureFullUrl(url, basePath) {
return url.startsWith("http") ? url : `${basePath}${url}`;
}
// Merge nextUrl query parameters with options query parameters (usually filters)
export function getQueryParams(nextUrl, options) {
const nextOptions = { query: {} };
if (nextUrl) {
nextOptions.query = {};
if (!nextUrl.includes("?")) {
throw new Error("nextUrl must contains query parameters");
}
new URLSearchParams(nextUrl.split("?")[1]).forEach((value, key) => {
nextOptions.query[key] = value;
});
}
if (options) {
nextOptions.query = { ...nextOptions.query, ...options.query };
}
return nextOptions;
}
export class BaseAPI {
configuration;
filterFields;
constructor(configuration, filterFields) {
this.configuration = configuration;
this.filterFields = filterFields || [];
}
async requestObject(url, options = {}, fields) {
if (!this.configuration.basePath) {
throw new Error("Base path is not configured for API requests");
}
if (this.configuration.headers) {
options.headers = {
...this.configuration.headers,
...options.headers,
};
}
const response = await fetch(ensureFullUrl(url, this.configuration.basePath), {
...options,
headers: {
...options.headers,
...this.configuration.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Request failed with status ${response.status}: ${errorText}`);
}
const apiResponse = {
status: response.status,
headers: response.headers,
body: convertKeysToCamelCase(await response.json()),
};
if (fields) {
apiResponse.body = pickFields(apiResponse.body, fields);
}
if (this.filterFields) {
this.sanitizeResponse(apiResponse.body);
}
return apiResponse;
}
/**
* Fetches an array of resources from the API with support for pagination and field filtering.
*
* @template T - The type of objects in the response array, must extend Record<string, any>
* @param url - The API endpoint URL to fetch data from
* @param options - Optional request configuration including headers and other fetch options
* @param fetchAll - Whether to automatically fetch all pages of paginated results (default: false)
* @param fields - Optional array of field names to include in the response objects
* @returns A Promise resolving to an ApiResponse containing an array of type T
*
* @throws {ToolError} When the HTTP request fails with a non-ok status
* @throws {Error} When the response data is not an array
*
* @example
* ```typescript
* const response = await client.requestArray<User>('/users', {}, true, ['id', 'name']);
* console.log(response.body); // Array of User objects with only id and name fields
* ```
*/
async requestArray(url, options = {}, fetchAll = true, fields) {
let results = [];
let nextUrl = url;
let apiResponse;
do {
nextUrl = ensureFullUrl(nextUrl, this.configuration.basePath);
const response = await fetch(nextUrl, {
...options,
headers: {
...options.headers,
...this.configuration.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new ToolError(`Request failed with status ${response.status}: ${errorText}`);
}
const data = convertKeysToCamelCase(await response.json());
nextUrl = getNextUrlPathFromHeader(response.headers, this.configuration.basePath);
if (!Array.isArray(data)) {
throw new Error("Expected response to be an array");
}
results = results.concat(data);
apiResponse = {
status: response.status,
headers: response.headers,
nextUrl: nextUrl,
totalCount: getTotalCountFromHeader(response.headers),
body: results,
};
} while (fetchAll && nextUrl);
if (fields) {
apiResponse.body = pickFieldsFromArray(apiResponse.body, fields);
}
if (this.filterFields) {
apiResponse.body.forEach((item) => {
this.sanitizeResponse(item);
});
}
return apiResponse;
}
sanitizeResponse(data) {
if (!data)
return;
for (const key of this.filterFields) {
if (key in data) {
delete data[key];
}
}
}
}