@smartbear/mcp
Version:
MCP server for interacting SmartBear Products
674 lines (673 loc) • 28.9 kB
JavaScript
import { ToolError } from "../../common/types.js";
// Regex to extract owner, name, and version from SwaggerHub URLs.
// Matches /apis/owner/name/version, /domains/owner/name/version, or /templates/owner/name/version
// Example: /apis/acme/petstore/1.0.0
// - group 1: type (apis|domains|templates)
// - group 2: owner
// - group 3: name
// - group 4: version
const SWAGGER_URL_REGEX = /\/(apis|domains|templates)\/([^/]+)\/([^/]+)\/([^/]+)/;
/**
* Type guard to check if a value has an 'id' property
*/
function hasId(value) {
return typeof value === "object" && value !== null && "id" in value;
}
/**
* Type guard to check if a value has a 'message' property
*/
function hasMessage(value) {
return typeof value === "object" && value !== null && "message" in value;
}
/**
* Type guard to check if a value has an 'errorsFound' property
*/
function hasErrorsFound(value) {
return typeof value === "object" && value !== null && "errorsFound" in value;
}
export class SwaggerAPI {
config;
headers;
constructor(config, userAgent) {
this.config = config;
this.headers = config.getHeaders(userAgent);
}
/**
* Core response parsing logic shared between different response handlers.
* Handles 204 No Content, empty responses, JSON parsing, and text fallbacks.
* @template T - Expected JSON response data type
* @template D - Default return type for empty responses
* @param response - The fetch Response object
* @param defaultReturn - Default value to return for empty responses
* @returns Parsed response data or fallback value
*/
async parseResponse(response, defaultReturn = {}) {
// Handle 204 No Content responses
if (response.status === 204) {
return defaultReturn;
}
// Check if response has content-length of 0 (empty body)
const contentLength = response.headers.get("content-length");
if (contentLength === "0") {
return defaultReturn;
}
// Check if response is JSON
const contentType = response.headers.get("content-type");
if (contentType?.includes("application/json")) {
try {
return (await response.json());
}
catch (error) {
console.warn("Failed to parse JSON response (declared JSON):", error);
return defaultReturn;
}
}
// Fallback: read text and attempt heuristic JSON parse
const text = await response.text();
if (!text)
return defaultReturn;
const trimmed = text.trim();
const firstChar = trimmed[0];
if (firstChar === "{" || firstChar === "[") {
try {
return JSON.parse(trimmed);
}
catch (error) {
console.warn("Heuristic JSON parse failed:", error);
return { message: text };
}
}
return { message: text };
}
/**
* Handles HTTP responses with smart JSON parsing and fallback handling.
* Includes HTTP error checking before parsing.
* @template T - Expected response data type
* @param response - The fetch Response object
* @param defaultReturn - Default value to return for empty responses
* @returns Parsed response data or fallback value
*/
async handleResponse(response, defaultReturn = {}) {
if (!response.ok) {
throw new ToolError(`HTTP ${response.status}: ${response.statusText}`);
}
return this.parseResponse(response, defaultReturn);
}
async getPortals() {
const response = await fetch(`${this.config.portalBasePath}/portals`, {
method: "GET",
headers: this.headers,
});
const result = await this.handleResponse(response, []);
return result;
}
async getOrganizations(params) {
// Build query string if parameters are provided
const searchParams = new URLSearchParams();
if (params?.q)
searchParams.append("q", params.q);
if (params?.sortBy)
searchParams.append("sortBy", params.sortBy);
if (params?.order)
searchParams.append("order", params.order);
if (params?.page !== undefined)
searchParams.append("page", params.page.toString());
if (params?.pageSize)
searchParams.append("pageSize", params.pageSize.toString());
const queryString = searchParams.toString();
const url = `${this.config.userManagementBasePath}/orgs${queryString ? `?${queryString}` : ""}`;
const response = await fetch(url, {
method: "GET",
headers: this.headers,
});
const defaultResponse = {
items: [],
totalCount: 0,
pageSize: 50,
page: 0,
};
const result = await this.handleResponse(response, defaultResponse);
return result;
}
async createPortal(body) {
const response = await fetch(`${this.config.portalBasePath}/portals`, {
method: "POST",
headers: this.headers,
body: JSON.stringify(body),
});
const result = await this.handleResponse(response);
if (!hasId(result)) {
throw new Error("Unexpected empty response creating portal");
}
return result;
}
async getPortal(portalId) {
const response = await fetch(`${this.config.portalBasePath}/portals/${portalId}`, {
method: "GET",
headers: this.headers,
});
const result = await this.handleResponse(response);
if (!hasId(result)) {
throw new ToolError("Portal not found or empty response");
}
return result;
}
async updatePortal(portalId, body) {
const response = await fetch(`${this.config.portalBasePath}/portals/${portalId}`, {
method: "PATCH",
headers: this.headers,
body: JSON.stringify(body),
});
return this.handleResponse(response);
}
async getPortalProducts(portalId) {
const response = await fetch(`${this.config.portalBasePath}/portals/${portalId}/products`, {
method: "GET",
headers: this.headers,
});
const result = await this.handleResponse(response, []);
return result;
}
async createPortalProduct(portalId, body) {
const response = await fetch(`${this.config.portalBasePath}/portals/${portalId}/products`, {
method: "POST",
headers: this.headers,
body: JSON.stringify(body),
});
const result = await this.handleResponse(response);
if (!hasId(result)) {
throw new Error("Unexpected empty response creating product");
}
return result;
}
async getPortalProduct(productId) {
const response = await fetch(`${this.config.portalBasePath}/products/${productId}`, {
method: "GET",
headers: this.headers,
});
const result = await this.handleResponse(response);
if (!hasId(result)) {
throw new ToolError("Product not found or empty response");
}
return result;
}
async deletePortalProduct(productId) {
const response = await fetch(`${this.config.portalBasePath}/products/${productId}`, {
method: "DELETE",
headers: this.headers,
});
return this.handleResponse(response);
}
async updatePortalProduct(productId, body) {
const response = await fetch(`${this.config.portalBasePath}/products/${productId}`, {
method: "PATCH",
headers: this.headers,
body: JSON.stringify(body),
});
return this.handleResponse(response, {
success: true,
});
}
async publishPortalProduct(productId, preview = false) {
const response = await fetch(`${this.config.portalBasePath}/products/${productId}/published-content?preview=${preview}`, {
method: "PUT",
headers: this.headers,
});
return this.handleResponse(response, {
success: true,
});
}
async getPortalProductSections(productId, params) {
const queryParameters = new URLSearchParams();
if (params.embed) {
for (const item of params.embed) {
queryParameters.append("embed", item);
}
}
if (params.page !== undefined) {
queryParameters.append("page", params.page.toString());
}
if (params.size !== undefined) {
queryParameters.append("size", params.size.toString());
}
const url = `${this.config.portalBasePath}/products/${productId}/sections${queryParameters.toString() ? `?${queryParameters.toString()}` : ""}`;
const response = await fetch(url, {
method: "GET",
headers: this.headers,
});
const result = await this.handleResponse(response, []);
return result;
}
/**
* Create a new table of contents item in a portal product section
* @param sectionId - Section ID where the table of contents item will be created
* @param body - Table of contents creation parameters
* @returns Created table of contents item with metadata
*/
async createTableOfContents(sectionId, body) {
const url = `${this.config.portalBasePath}/sections/${sectionId}/table-of-contents`;
const response = await fetch(url, {
method: "POST",
headers: this.headers,
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API Hub createTableOfContents failed - status: ${response.status} ${response.statusText}. Response: ${errorText}`);
}
const result = await response.json();
return result;
}
/**
* Get table of contents for a section
* @param args - Parameters for retrieving table of contents
* @returns List of table of contents items
*/
async getTableOfContents(args) {
const { sectionId, embed, page, size } = args;
const searchParams = new URLSearchParams();
if (embed) {
for (const item of embed) {
searchParams.append("embed", item);
}
}
if (page !== undefined) {
searchParams.set("page", page.toString());
}
if (size !== undefined) {
searchParams.set("size", size.toString());
}
const url = `${this.config.portalBasePath}/sections/${sectionId}/table-of-contents${searchParams.toString() ? `?${searchParams.toString()}` : ""}`;
const response = await fetch(url, {
method: "GET",
headers: this.headers,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API Hub getTableOfContents failed - status: ${response.status} ${response.statusText}. Response: ${errorText}`);
}
const result = await response.json();
// The API returns a paginated response, so we extract the items array
return result.items;
}
/**
* Get document content and metadata
* @param args - Parameters for retrieving document
* @returns Document with content and metadata
*/
async getDocument(args) {
const { documentId } = args;
const url = `${this.config.portalBasePath}/documents/${documentId}`;
const response = await fetch(url, {
method: "GET",
headers: this.headers,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API Hub getDocument failed - status: ${response.status} ${response.statusText}. Response: ${errorText}`);
}
const result = await response.json();
return result;
}
/**
* Update document content
* @param args - Parameters for updating document
* @returns Success response
*/
async updateDocument(args) {
const { documentId, ...body } = args;
const url = `${this.config.portalBasePath}/documents/${documentId}`;
const response = await fetch(url, {
method: "PATCH",
headers: this.headers,
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API Hub updateDocument failed - status: ${response.status} ${response.statusText}. Response: ${errorText}`);
}
return { success: true };
}
/**
* Delete table of contents entry
* @param args - Parameters for deleting table of contents entry
* @returns Success response
*/
async deleteTableOfContents(args) {
const { tableOfContentsId, recursive } = args;
const searchParams = new URLSearchParams();
if (recursive !== undefined) {
searchParams.set("recursive", recursive.toString());
}
const url = `${this.config.portalBasePath}/table-of-contents/${tableOfContentsId}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`;
const response = await fetch(url, {
method: "DELETE",
headers: this.headers,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API Hub deleteTableOfContents failed - status: ${response.status} ${response.statusText}. Response: ${errorText}`);
}
return { success: true };
}
/**
* Helper method for handling responses when error checking is already done.
* Delegates to parseResponse for the actual parsing logic.
* @template T - Expected response data type
* @template D - Default return type
* @param response - The fetch Response object
* @param defaultReturn - Default value to return for empty responses
* @returns Parsed response data or fallback value
*/
// Registry API methods for SwaggerHub Design functionality
/**
* Search APIs and Domains in SwaggerHub Registry using /specs endpoint
* @param params Search parameters
* @returns Array of processed API metadata
*/
async searchApis(params = {}) {
const searchParams = new URLSearchParams();
if (params.query)
searchParams.append("query", params.query);
if (params.state)
searchParams.append("state", params.state);
if (params.tag)
searchParams.append("tag", params.tag);
if (params.offset !== undefined)
searchParams.append("offset", params.offset.toString());
if (params.limit !== undefined)
searchParams.append("limit", params.limit.toString());
if (params.sort)
searchParams.append("sort", params.sort);
if (params.order)
searchParams.append("order", params.order);
if (params.owner)
searchParams.append("owner", params.owner);
if (params.specType)
searchParams.append("specType", params.specType);
const url = `${this.config.registryBasePath}/specs${searchParams.toString() ? `?${searchParams.toString()}` : ""}`;
const response = await fetch(url, {
method: "GET",
headers: this.headers,
});
if (!response.ok) {
throw new ToolError(`SwaggerHub Registry API searchApis failed - status: ${response.status} ${response.statusText}`);
}
const apisJsonResponse = (await response.json());
// Transform APIs.json response to our ApiMetadata format
return this.transformApisJsonToMetadata(apisJsonResponse.apis);
}
/**
* Transform APIs.json specifications to our ApiMetadata format
* @param specs Array of API specifications from APIs.json
* @returns Array of processed API metadata
*/
transformApisJsonToMetadata(specs) {
return specs.map((spec) => {
// Extract useful properties from the properties array
const properties = spec.properties || [];
const getProperty = (type) => {
const property = properties.find((p) => p.type === type);
return property?.value || property?.url;
};
// Extract owner, name, and version from the Swagger URL using the regex constant
const swaggerUrl = getProperty("Swagger") || "";
const urlMatch = RegExp(SWAGGER_URL_REGEX).exec(swaggerUrl);
return {
owner: urlMatch?.[2] || "",
name: spec.name || "",
description: spec.description || "",
summary: spec.summary || "",
version: getProperty("X-Version") || urlMatch?.[4] || "",
specification: getProperty("X-Specification") || "",
created: getProperty("X-Created"),
modified: getProperty("X-Modified"),
published: getProperty("X-Published"),
private: getProperty("X-Private"),
oasVersion: getProperty("X-OASVersion"),
url: swaggerUrl,
};
});
}
/**
* Get API definition from SwaggerHub Registry
* @param params Parameters including owner, api name, version, and options
* @returns API definition (OpenAPI/Swagger specification)
*/
async getApiDefinition(params) {
const searchParams = new URLSearchParams();
if (params.resolved !== undefined)
searchParams.append("resolved", params.resolved.toString());
if (params.flatten !== undefined)
searchParams.append("flatten", params.flatten.toString());
const url = `${this.config.registryBasePath}/apis/${encodeURIComponent(params.owner)}/${encodeURIComponent(params.api)}/${encodeURIComponent(params.version)}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`;
const response = await fetch(url, {
method: "GET",
headers: this.headers,
});
if (!response.ok) {
throw new ToolError(`SwaggerHub Registry API getApiDefinition failed - status: ${response.status} ${response.statusText}`);
}
// Return the raw API definition (could be JSON or YAML)
const contentType = response.headers.get("content-type");
if (contentType?.includes("application/json")) {
return response.json();
}
else {
return response.text();
}
}
/**
* Create or Update API in SwaggerHub Registry
* @param params Parameters for creating or updating the API including owner, name, version, specification, and definition
* @returns Created or updated API metadata with URL. HTTP 201 indicates creation, HTTP 200 indicates update
*/
async createOrUpdateApi(params) {
// Determine the format of the definition
let contentType;
let requestBody;
// Auto-detect format from the definition content
const format = this.detectDefinitionFormat(params.definition);
if (format === "yaml") {
contentType = "application/yaml";
requestBody = params.definition; // Send YAML as-is
}
else {
contentType = "application/json";
// For JSON, parse and stringify to ensure valid JSON
try {
const parsedDefinition = JSON.parse(params.definition);
requestBody = JSON.stringify(parsedDefinition);
}
catch (error) {
throw new ToolError(`Invalid JSON format in definition: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
// Construct the URL with query parameters
// Fixed values: visibility=private, automock=false, version=1.0.0
const searchParams = new URLSearchParams();
searchParams.append("isPrivate", "true");
const url = `${this.config.registryBasePath}/apis/${encodeURIComponent(params.owner)}/${encodeURIComponent(params.apiName)}?${searchParams.toString()}`;
// Use POST method with the appropriate content type
const response = await fetch(url, {
method: "POST",
headers: {
...this.headers,
"Content-Type": contentType,
},
body: requestBody,
});
if (!response.ok) {
const errorText = await response.text().catch(() => "");
throw new ToolError(`SwaggerHub Registry API createOrUpdateApi failed - status: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ""}. URL: ${url}`);
}
// Determine operation type based on HTTP status code
const operation = response.status === 201 ? "create" : "update";
// Return formatted response with the required fields
// Fixed version is always 1.0.0
return {
owner: params.owner,
apiName: params.apiName,
version: "1.0.0",
url: `${this.config.uiBasePath}/apis/${params.owner}/${params.apiName}/1.0.0`,
operation,
};
}
/**
* Create API from Template in SwaggerHub Registry
* @param params Parameters for creating API from template including owner, api name, and template
* @returns Created API metadata with URL. HTTP 201 indicates creation, HTTP 200 indicates update
*/
async createApiFromTemplate(params) {
// Construct the URL with query parameters
// Fixed values: visibility=private, no project, noReconcile=false
const searchParams = new URLSearchParams();
searchParams.append("isPrivate", "true");
searchParams.append("template", params.template);
const url = `${this.config.registryBasePath}/apis/${encodeURIComponent(params.owner)}/${encodeURIComponent(params.apiName)}/.template?${searchParams.toString()}`;
// Use POST method for template creation
const response = await fetch(url, {
method: "POST",
headers: this.headers,
});
if (!response.ok) {
const errorText = await response.text().catch(() => "");
throw new ToolError(`SwaggerHub Registry API createApiFromTemplate failed - status: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ""}. URL: ${url}`);
}
// Determine operation type based on HTTP status code
const operation = response.status === 201 ? "create" : "update";
// Return formatted response with the required fields
return {
owner: params.owner,
apiName: params.apiName,
template: params.template,
url: `${this.config.uiBasePath}/apis/${params.owner}/${params.apiName}`,
operation,
};
}
/**
* Create API from Prompt using SmartBear AI
* @param params Parameters for creating API from prompt including owner, api name, prompt, and specification type
* @returns Created API metadata with URL. HTTP 201 indicates creation, HTTP 200 for update, HTTP 205 for reload
*/
async createApiFromPrompt(params) {
// Construct the URL with query parameters
const searchParams = new URLSearchParams();
const specType = params.specType ?? "openapi30x";
searchParams.append("specType", specType);
const url = `${this.config.registryBasePath}/apis/${encodeURIComponent(params.owner)}/${encodeURIComponent(params.apiName)}/.ai?${searchParams.toString()}`;
// Use POST method with JSON body containing the prompt
const response = await fetch(url, {
method: "POST",
headers: {
...this.headers,
"Content-Type": "application/json",
},
body: JSON.stringify(params.prompt),
});
if (!response.ok) {
const errorText = await response.text().catch(() => "");
throw new ToolError(`SwaggerHub Registry API createApiFromPrompt failed - status: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ""}. URL: ${url}`);
}
// Determine operation type based on HTTP status code
// 201 = new API created, 200 = existing API updated, 205 = API saved and should be reloaded
const operation = response.status === 201 ? "create" : "update";
// Extract version from X-Version header
const version = response.headers.get("X-Version");
// Return formatted response with the required fields
return {
owner: params.owner,
apiName: params.apiName,
specType: specType,
version: version || undefined,
url: version
? `${this.config.uiBasePath}/apis/${params.owner}/${params.apiName}/${version}`
: `${this.config.uiBasePath}/apis/${params.owner}/${params.apiName}`,
operation,
};
}
/**
* Auto-detect the format of an API definition string
* @param definition The API definition content
* @returns 'json' or 'yaml'
*/
detectDefinitionFormat(definition) {
const trimmed = definition.trim();
if (!trimmed) {
throw new ToolError("Empty definition content provided");
}
try {
JSON.parse(trimmed);
return "json";
}
catch {
return "yaml";
}
}
/**
* Run a standardization scan against an API definition
* @param params Parameters including organization name and API definition
* @returns Standardization result with validation errors
*/
async scanStandardization(params) {
// Auto-detect format from the definition content
const format = this.detectDefinitionFormat(params.definition);
let contentType;
let requestBody;
if (format === "yaml") {
contentType = "application/yaml";
requestBody = params.definition; // Send YAML as-is
}
else {
contentType = "application/json";
// For JSON, parse and stringify to ensure valid JSON
try {
const parsedDefinition = JSON.parse(params.definition);
requestBody = JSON.stringify(parsedDefinition);
}
catch (error) {
throw new ToolError(`Invalid JSON format in definition: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
const url = `${this.config.registryBasePath}/standardization/${encodeURIComponent(params.orgName)}/scan`;
const response = await fetch(url, {
method: "POST",
headers: {
...this.headers,
"Content-Type": contentType,
},
body: requestBody,
});
if (!response.ok) {
const errorText = await response.text().catch(() => "");
throw new ToolError(`SwaggerHub Registry API scanStandardization failed - status: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ""}. URL: ${url}`);
}
return await this.handleResponse(response);
}
/**
* Standardize and fix an API definition using AI
* @param params Parameters including owner, API name, and version
* @returns Standardization response with status and fixed definition
*/
async standardizeApi(params) {
const url = `${this.config.registryBasePath}/apis/${encodeURIComponent(params.owner)}/${encodeURIComponent(params.api)}/${encodeURIComponent(params.version)}/standardize`;
const response = await fetch(url, {
method: "POST",
headers: this.headers,
});
if (!response.ok) {
const errorText = await response.text().catch(() => "");
throw new ToolError(`SwaggerHub Registry API standardizeApi failed - status: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ""}. URL: ${url}`);
}
const result = await this.handleResponse(response);
// Validate that we have the expected response structure
if (!hasMessage(result)) {
throw new ToolError("Unexpected response format from standardizeApi endpoint");
}
// If errorsFound is not present, default to 0 (no errors found)
if (!hasErrorsFound(result)) {
return { ...result, errorsFound: 0 };
}
return result;
}
}