UNPKG

@smartbear/mcp

Version:

MCP server for interacting SmartBear Products

341 lines (340 loc) 14.2 kB
import z from "zod"; import { MCP_SERVER_NAME, MCP_SERVER_VERSION } from "../common/info.js"; import { ToolError, } from "../common/types.js"; import { getOADMatcherRecommendations, getUserMatcherSelection, } from "./client/prompt-utils.js"; import { PROMPTS } from "./client/prompts.js"; import { TOOLS } from "./client/tools.js"; const ConfigurationSchema = z.object({ base_url: z.string().url().describe("Pact Broker or PactFlow base URL"), token: z .string() .optional() .describe("Bearer token for PactFlow authentication (use this OR username/password)"), username: z.string().optional().describe("Username for Pact Broker"), password: z.string().optional().describe("Password for Pact Broker"), }); // Tool definitions for PactFlow AI API client export class PactflowClient { name = "Contract Testing"; toolPrefix = "contract-testing"; configPrefix = "Pact-Broker"; config = ConfigurationSchema; headers; aiBaseUrl; baseUrl; _clientType; _server; get server() { if (!this._server) throw new Error("Server not configured"); return this._server; } get clientType() { if (!this._clientType) throw new Error("Client not configured"); return this._clientType; } async configure(server, config) { // Set headers based on the type of auth provided if (typeof config.token === "string") { this.headers = { Authorization: `Bearer ${config.token}`, "Content-Type": "application/json", "User-Agent": `${MCP_SERVER_NAME}/${MCP_SERVER_VERSION}`, }; this._clientType = "pactflow"; } else if (typeof config.username === "string" && typeof config.password === "string") { const authString = `${config.username}:${config.password}`; this.headers = { Authorization: `Basic ${Buffer.from(authString).toString("base64")}`, "Content-Type": "application/json", "User-Agent": `${MCP_SERVER_NAME}/${MCP_SERVER_VERSION}`, }; this._clientType = "pact_broker"; } else { return false; // Don't configure the client if no auth is provided } this.baseUrl = config.base_url; this.aiBaseUrl = `${this.baseUrl}/api/ai`; this._server = server.server; return true; } // PactFlow AI client methods /** * Generate new Pact tests based on the provided input. * * @param toolInput The input data for the generation process. * @param getInput Function to get additional input from the user if needed. * @returns The result of the generation process. * @throws Error if the HTTP request fails or the operation times out. */ async generate(toolInput, getInput) { if (toolInput.openapi?.document && (!toolInput.openapi?.matcher || Object.keys(toolInput.openapi.matcher).length === 0)) { const matcherResponse = await getOADMatcherRecommendations(toolInput.openapi.document, this.server); const userSelection = await getUserMatcherSelection(matcherResponse, getInput); toolInput.openapi.matcher = userSelection; } // Submit the generation request const response = await fetch(`${this.aiBaseUrl}/generate`, { method: "POST", headers: this.headers, body: JSON.stringify(toolInput), }); if (!response.ok) { throw new ToolError(`HTTP error! status: ${response.status} - ${await response.text()}`); } const status_response = await response.json(); return await this.pollForCompletion(status_response, "Generation"); } /** * Review the provided Pact tests and suggest improvements. * * @param toolInput The input data for the review process. * @param getInput Function to get additional input from the user if needed. * @returns The result of the review process. * @throws Error if the HTTP request fails or the operation times out. */ async review(toolInput, getInput) { if (toolInput.openapi?.document && (!toolInput.openapi?.matcher || Object.keys(toolInput.openapi.matcher).length === 0)) { const matcherResponse = await getOADMatcherRecommendations(toolInput.openapi.document, this.server); const userSelection = await getUserMatcherSelection(matcherResponse, getInput); toolInput.openapi.matcher = userSelection; } // Submit review request const response = await fetch(`${this.aiBaseUrl}/review`, { method: "POST", headers: this.headers, body: JSON.stringify(toolInput), }); if (!response.ok) { throw new ToolError(`HTTP error! status: ${response.status} - ${await response.text()}`); } const status_response = await response.json(); return await this.pollForCompletion(status_response, "Review Pacts"); } /** * Retrieve PactFlow AI entitlement information for the current user * and organization when encountering 401 unauthorized errors. * Use this to check AI entitlements and credits when AI operations fail. * * @returns Entitlement containing permissions, organization * entitlements, and user entitlements. * @throws Error if the request fails or returns a non-OK response. */ async checkAIEntitlements() { const url = `${this.aiBaseUrl}/entitlement`; try { const response = await fetch(url, { method: "GET", headers: this.headers, }); if (!response.ok) { const errorText = await response.text().catch(() => ""); throw new ToolError(`PactFlow AI Entitlements Request Failed - status: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ""}`); } return (await response.json()); } catch (error) { process.stderr.write(`[CheckAIEntitlements] Unexpected error: ${error}\n`); throw error; } } async getStatus(statusUrl) { const response = await fetch(statusUrl, { method: "HEAD", headers: this.headers, }); return { status: response.status, isComplete: response.status === 200, }; } get requestHeaders() { return this.headers; } async getResult(resultUrl) { const response = await fetch(resultUrl, { method: "GET", headers: this.headers, }); // Check if the response is OK (status 200) if (!response.ok) { throw new ToolError(`HTTP error! status: ${response.status}`); } return response.json(); } async pollForCompletion(status_response, operationName) { // Polling for completion const startTime = Date.now(); const timeout = 120000; // 120 seconds const pollInterval = 1000; // 1 second while (Date.now() - startTime < timeout) { const statusCheck = await this.getStatus(status_response.status_url); if (statusCheck.isComplete) { // Operation is complete, get the result return await this.getResult(status_response.result_url); } if (statusCheck.status !== 202) { throw new ToolError(`${operationName} failed with status: ${statusCheck.status}`); } // Wait before next poll await new Promise((resolve) => setTimeout(resolve, pollInterval)); } throw new ToolError(`${operationName} timed out after ${timeout / 1000} seconds`); } // PactFlow / Pact_Broker client methods async getProviderStates({ provider, }) { const uri_encoded_provider_name = encodeURIComponent(provider); const response = await fetch(`${this.baseUrl}/pacts/provider/${uri_encoded_provider_name}/provider-states`, { method: "GET", headers: this.headers, }); if (!response.ok) { throw new ToolError(`HTTP error! status: ${response.status} - ${await response.text()}`); } return response.json(); } /** * Checks if a given pacticipant version is safe to deploy * to a specified environment. * * @param body - Input containing: * - `pacticipant`: The name of the service (pacticipant). * - `version`: The version of the pacticipant being evaluated for deployment. * - `environment`: The target environment (e.g., staging, production). * @returns CanIDeployResponse containing deployment decision and verification results. * @throws Error if the request fails or returns a non-OK response. */ async canIDeploy(body) { const { pacticipant, version, environment } = body; const queryParams = new URLSearchParams({ pacticipant, version, environment, }); const url = `${this.baseUrl}/can-i-deploy?${queryParams.toString()}`; try { const response = await fetch(url, { method: "GET", headers: this.headers, }); if (!response.ok) { const errorText = await response.text().catch(() => ""); throw new ToolError(`Can-I-Deploy Request Failed - status: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ""}`); } return (await response.json()); } catch (error) { console.error(`[CanIDeploy] Unexpected error: ${error}\n`); throw error; } } /** * Retrieves the matrix of pact verification results for the specified pacticipants. * This allows you to see which consumer/provider combinations have been verified * and make deployment decisions based on contract test results. * * @param body - Matrix query parameters including pacticipants, versions, environments, etc. * @returns MatrixResponse containing the verification matrix, notices, and summary * @throws Error if the request fails or returns a non-OK response */ async getMatrix(body) { const { q, latestby, limit } = body; // Build query parameters manually to avoid URL encoding of square brackets const queryParts = []; // Add optional parameters if (latestby) { queryParts.push(`latestby=${encodeURIComponent(latestby)}`); } if (limit !== undefined) { queryParts.push(`limit=${limit}`); } // Add the q parameters (pacticipant selectors) q.forEach((selector) => { queryParts.push(`q[]pacticipant=${encodeURIComponent(selector.pacticipant)}`); if (selector.version) { queryParts.push(`q[]version=${encodeURIComponent(selector.version)}`); } if (selector.branch) { queryParts.push(`q[]branch=${encodeURIComponent(selector.branch)}`); } if (selector.environment) { queryParts.push(`q[]environment=${encodeURIComponent(selector.environment)}`); } if (selector.latest !== undefined) { queryParts.push(`q[]latest=${selector.latest}`); } if (selector.tag) { queryParts.push(`q[]tag=${encodeURIComponent(selector.tag)}`); } if (selector.mainBranch !== undefined) { queryParts.push(`q[]mainBranch=${selector.mainBranch}`); } }); const url = `${this.baseUrl}/matrix?${queryParts.join("&")}`; try { const response = await fetch(url, { method: "GET", headers: this.headers, }); if (!response.ok) { const errorText = await response.text().catch(() => ""); throw new ToolError(`Matrix Request Failed - status: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ""}`); } return (await response.json()); } catch (error) { console.error("[GetMatrix] Unexpected error:", error); throw error; } } /** * Registers tools with the provided register function. * * @param register - The function used to register tools. * @param getInput - The function used to get input for tools. */ registerTools(register, getInput) { for (const tool of TOOLS.filter((t) => t.clients.includes(this.clientType))) { const { handler, clients: _, formatResponse, ...toolparams } = tool; register(toolparams, async (args, _extra) => { const handler_fn = this[handler]; if (typeof handler_fn !== "function") { throw new Error(`Handler '${handler}' not found on PactClient`); } let result; if (tool.enableElicitation) { result = await handler_fn.call(this, args, getInput); } else { result = await handler_fn.call(this, args); } // Use custom response formatter if provided if (formatResponse) { return formatResponse(result); } // Default fallback return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; }); } } /** * Registers prompts with the provided register function. * * @param register - The function used to register prompts. */ registerPrompts(register) { PROMPTS.forEach((prompt) => { register(prompt.name, prompt.params, prompt.callback); }); } }