UNPKG

@smartbear/mcp

Version:

MCP server for interacting SmartBear Products

192 lines (191 loc) 6.98 kB
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]; } } } }