UNPKG

zephyr-scale-mcp-server

Version:

Model Context Protocol (MCP) server for Zephyr Scale test case management with comprehensive STEP_BY_STEP, PLAIN_TEXT, and BDD support

563 lines (562 loc) 25 kB
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { convertToGherkin } from './utils.js'; export class ZephyrToolHandlers { axiosInstance; constructor(axiosInstance) { this.axiosInstance = axiosInstance; } async getTestCase(args) { const { test_case_key } = args; try { const response = await this.axiosInstance.get(`/rest/atm/1.0/testcase/${test_case_key}`); return { content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }], }; } catch (error) { throw new McpError(ErrorCode.InternalError, `Failed to get test case: ${error instanceof Error ? error.message : String(error)}`); } } async createTestCase(args) { const { project_key, name, test_script, folder, status, priority, precondition, objective, component, owner, estimated_time, labels, issue_links, custom_fields, parameters } = args; // Build the basic payload const payload = { projectKey: project_key, name: name }; // Add optional fields if (folder) payload.folder = folder; if (status) payload.status = status; if (priority) payload.priority = priority; if (precondition) payload.precondition = precondition; if (objective) payload.objective = objective; if (component) payload.component = component; if (owner) payload.owner = owner; if (estimated_time) payload.estimatedTime = estimated_time; if (labels && labels.length > 0) payload.labels = labels; if (issue_links && issue_links.length > 0) payload.issueLinks = issue_links; if (custom_fields) payload.customFields = custom_fields; if (parameters) payload.parameters = parameters; // Handle test script if (test_script) { payload.testScript = { type: test_script.type }; if (test_script.type === 'STEP_BY_STEP' && test_script.steps) { payload.testScript.steps = test_script.steps.map((step) => { const stepObj = {}; if (step.description) stepObj.description = step.description; if (step.testData) stepObj.testData = step.testData; if (step.expectedResult) stepObj.expectedResult = step.expectedResult; if (step.testCaseKey) stepObj.testCaseKey = step.testCaseKey; return stepObj; }); } else if ((test_script.type === 'PLAIN_TEXT' || test_script.type === 'BDD') && test_script.text) { if (test_script.type === 'BDD') { const gherkinContent = convertToGherkin(test_script.text); payload.testScript.text = gherkinContent || test_script.text; } else { payload.testScript.text = test_script.text; } } } // Always set status to Draft for new test cases payload.status = 'Draft'; try { const response = await this.axiosInstance.post('/rest/atm/1.0/testcase', payload); if (response.status === 201) { const testKey = response.data.key || 'Unknown'; return { content: [ { type: 'text', text: `✅ Test case created successfully: ${testKey}\n${JSON.stringify({ key: testKey, type: test_script?.type || 'none', hasSteps: test_script?.type === 'STEP_BY_STEP' ? test_script.steps?.length || 0 : undefined, hasText: (test_script?.type === 'PLAIN_TEXT' || test_script?.type === 'BDD') ? !!test_script.text : undefined }, null, 2)}`, }, ], }; } else { throw new Error(`Unexpected status code: ${response.status}`); } } catch (error) { let errorMessage = 'Unknown error'; if (error instanceof Error && 'response' in error) { const axiosError = error; errorMessage = `Status: ${axiosError.response?.status}, Data: ${JSON.stringify(axiosError.response?.data)}`; } else if (error instanceof Error) { errorMessage = error.message; } else { errorMessage = String(error); } throw new McpError(ErrorCode.InternalError, `Failed to create test case: ${errorMessage}`); } } async updateTestCaseBdd(args) { const { test_case_key, bdd_content } = args; try { // First get the current test case const getResponse = await this.axiosInstance.get(`/rest/atm/1.0/testcase/${test_case_key}`); if (getResponse.status !== 200) { throw new Error(`Failed to get test case ${test_case_key}`); } const gherkinContent = convertToGherkin(bdd_content); const payload = { testScript: { type: 'BDD', text: gherkinContent } }; const updateResponse = await this.axiosInstance.put(`/rest/atm/1.0/testcase/${test_case_key}`, payload); if (updateResponse.status === 200) { return { content: [ { type: 'text', text: `✅ Updated ${test_case_key} with BDD content successfully`, }, ], }; } else { throw new Error(`Failed to update ${test_case_key}: ${updateResponse.status}`); } } catch (error) { throw new McpError(ErrorCode.InternalError, `Failed to update test case BDD: ${error instanceof Error ? error.message : String(error)}`); } } async createFolder(args) { const { project_key, name, parent_folder_path, folder_type = 'TEST_CASE' } = args; let folderName = name; if (parent_folder_path && !name.startsWith('/')) { const parentPath = parent_folder_path.startsWith('/') ? parent_folder_path : `/${parent_folder_path}`; folderName = `${parentPath}/${name}`; } else if (!name.startsWith('/')) { folderName = `/${name}`; } const payload = { projectKey: project_key, name: folderName, type: folder_type, }; try { const response = await this.axiosInstance.post('/rest/atm/1.0/folder', payload); return { content: [ { type: 'text', text: `✅ Folder created successfully: ${response.data.name} (ID: ${response.data.id})\n${JSON.stringify(response.data, null, 2)}`, }, ], }; } catch (error) { throw new McpError(ErrorCode.InternalError, `Failed to create folder: ${error instanceof Error ? error.message : String(error)}`); } } async getTestRunCases(args) { const { test_run_key } = args; try { const response = await this.axiosInstance.get(`/rest/atm/1.0/testrun/${test_run_key}`); if (response.status === 200) { const data = response.data; const testCaseKeys = data.items.map((item) => item.testCaseKey); const statuses = data.items.map((item) => item.status); const runIds = data.items.map((item) => item.id); return { content: [ { type: 'text', text: `✅ Retrieved test cases from ${test_run_key}:\nTest Case Keys: ${JSON.stringify(testCaseKeys, null, 2)}\nStatuses: ${JSON.stringify(statuses, null, 2)}\nRun IDs: ${JSON.stringify(runIds, null, 2)}`, }, ], }; } else { throw new Error(`Failed to retrieve data: ${response.status}`); } } catch (error) { throw new McpError(ErrorCode.InternalError, `Failed to get test run cases: ${error instanceof Error ? error.message : String(error)}`); } } async deleteTestCase(args) { const { test_case_key } = args; try { const response = await this.axiosInstance.delete(`/rest/atm/1.0/testcase/${test_case_key}`); if (response.status === 204) { return { content: [ { type: 'text', text: `✅ Test case ${test_case_key} deleted successfully`, }, ], }; } else { throw new Error(`Unexpected status code: ${response.status}`); } } catch (error) { let errorMessage = 'Unknown error'; if (error instanceof Error && 'response' in error) { const axiosError = error; if (axiosError.response?.status === 404) { errorMessage = `Test case ${test_case_key} not found`; } else { errorMessage = `Status: ${axiosError.response?.status}, Data: ${JSON.stringify(axiosError.response?.data)}`; } } else if (error instanceof Error) { errorMessage = error.message; } else { errorMessage = String(error); } throw new McpError(ErrorCode.InternalError, `Failed to delete test case: ${errorMessage}`); } } async createTestRun(args) { const { project_key, name, test_case_keys, test_plan_key, folder, planned_start_date, planned_end_date, description, owner, environment, custom_fields } = args; // Build the basic payload const payload = { projectKey: project_key, name: name, }; // Add optional fields if (test_case_keys && test_case_keys.length > 0) { payload.items = test_case_keys.map((testCaseKey) => ({ testCaseKey: testCaseKey })); } if (folder) payload.folder = folder; if (planned_start_date) payload.plannedStartDate = planned_start_date; if (planned_end_date) payload.plannedEndDate = planned_end_date; if (description) payload.description = description; if (owner) payload.owner = owner; if (environment) payload.environment = environment; if (custom_fields) payload.customFields = custom_fields; if (test_plan_key) payload.testPlanKey = test_plan_key; try { const response = await this.axiosInstance.post('/rest/atm/1.0/testrun', payload); if (response.status === 201) { const testRunKey = response.data.key || 'Unknown'; return { content: [ { type: 'text', text: `✅ Test run created successfully: ${testRunKey}\n${JSON.stringify({ key: testRunKey, name: name, testCaseCount: test_case_keys?.length || 0, environment: environment || 'Not specified' }, null, 2)}`, }, ], }; } else { throw new Error(`Unexpected status code: ${response.status}`); } } catch (error) { let errorMessage = 'Unknown error'; if (error instanceof Error && 'response' in error) { const axiosError = error; errorMessage = `Status: ${axiosError.response?.status}, Data: ${JSON.stringify(axiosError.response?.data)}`; } else if (error instanceof Error) { errorMessage = error.message; } else { errorMessage = String(error); } throw new McpError(ErrorCode.InternalError, `Failed to create test run: ${errorMessage}`); } } async getTestRun(args) { const { test_run_key } = args; try { const response = await this.axiosInstance.get(`/rest/atm/1.0/testrun/${test_run_key}`); if (response.status === 200) { return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } else { throw new Error(`Failed to retrieve test run: ${response.status}`); } } catch (error) { let errorMessage = 'Unknown error'; if (error instanceof Error && 'response' in error) { const axiosError = error; if (axiosError.response?.status === 404) { errorMessage = `Test run ${test_run_key} not found`; } else { errorMessage = `Status: ${axiosError.response?.status}, Data: ${JSON.stringify(axiosError.response?.data)}`; } } else if (error instanceof Error) { errorMessage = error.message; } else { errorMessage = String(error); } throw new McpError(ErrorCode.InternalError, `Failed to get test run: ${errorMessage}`); } } async getTestExecution(args) { const { execution_id, test_run_keys } = args; // Require users to specify test runs to search - fail immediately if not provided if (!test_run_keys || !Array.isArray(test_run_keys) || test_run_keys.length === 0) { throw new McpError(ErrorCode.InvalidParams, 'test_run_keys is required. Please provide an array of test run keys to search in (e.g., ["PROJ-C152", "PROJ-C161"]). Use get_test_run_cases to find test runs if needed.'); } try { const testRunsToTry = test_run_keys; const searchResults = []; for (const testRunKey of testRunsToTry) { try { const response = await this.axiosInstance.get(`/rest/atm/1.0/testrun/${testRunKey}/testresults`); if (response.status === 200 && response.data) { // Look for the specific execution_id in the results const results = Array.isArray(response.data) ? response.data : [response.data]; const matchingExecution = results.find((result) => result.id && result.id.toString() === execution_id); if (matchingExecution) { return { content: [ { type: 'text', text: `✅ Test execution ${execution_id} found in ${testRunKey}:\n${JSON.stringify(matchingExecution, null, 2)}`, }, ], }; } // Store search info for debugging searchResults.push({ testRunKey, executionCount: results.length, executionIds: results.map((r) => r.id).slice(0, 5) // Show first 5 IDs }); } } catch (runError) { // Store error info for debugging searchResults.push({ testRunKey, error: runError instanceof Error ? runError.message : String(runError) }); continue; } } // If not found, provide helpful debugging info throw new Error(`Test execution ${execution_id} not found in any of the ${testRunsToTry.length} test runs searched. Search results: ${JSON.stringify(searchResults, null, 2)}`); } catch (error) { let errorMessage = 'Unknown error'; if (error instanceof Error) { errorMessage = error.message; } else { errorMessage = String(error); } throw new McpError(ErrorCode.InternalError, `Failed to get test execution: ${errorMessage}`); } } async searchTestCasesByFolder(args) { const { project_key, folder_path, max_results = 100 } = args; try { // Use the search endpoint with query parameter // The Zephyr Scale API requires a query parameter in JQL-like format const query = `projectKey = "${project_key}" AND folder = "${folder_path}"`; const params = { query: query, maxResults: max_results }; const response = await this.axiosInstance.get('/rest/atm/1.0/testcase/search', { params }); if (response.status === 200) { const testCases = response.data; const testCaseKeys = Array.isArray(testCases) ? testCases.map((tc) => tc.key) : testCases.values ? testCases.values.map((tc) => tc.key) : []; return { content: [ { type: 'text', text: `✅ Found ${testCaseKeys.length} test cases in folder "${folder_path}":\n${JSON.stringify({ folder: folder_path, testCaseKeys: testCaseKeys, totalCount: testCaseKeys.length }, null, 2)}`, }, ], }; } else { throw new Error(`Failed to search test cases: ${response.status}`); } } catch (error) { let errorMessage = 'Unknown error'; if (error instanceof Error && 'response' in error) { const axiosError = error; if (axiosError.response?.status === 404) { errorMessage = `Folder "${folder_path}" not found or no test cases found`; } else { errorMessage = `Status: ${axiosError.response?.status}, Data: ${JSON.stringify(axiosError.response?.data)}`; } } else if (error instanceof Error) { errorMessage = error.message; } else { errorMessage = String(error); } throw new McpError(ErrorCode.InternalError, `Failed to search test cases by folder: ${errorMessage}`); } } async addTestCasesToRun(args) { const { test_run_key, test_case_keys } = args; try { // Get the current test run first const getResponse = await this.axiosInstance.get(`/rest/atm/1.0/testrun/${test_run_key}`); if (getResponse.status !== 200) { throw new Error(`Failed to get test run ${test_run_key}`); } const currentTestRun = getResponse.data; const existingItems = currentTestRun.items || []; // Create new items for the test cases to add const newItems = test_case_keys.map((testCaseKey) => ({ testCaseKey: testCaseKey })); // Combine existing and new items (avoid duplicates) const existingKeys = existingItems.map((item) => item.testCaseKey); const uniqueNewItems = newItems.filter((item) => !existingKeys.includes(item.testCaseKey)); const updatedItems = [...existingItems, ...uniqueNewItems]; // Try multiple approaches to update the test run let response; let method = 'unknown'; // Approach 1: Try PUT with minimal payload try { const minimalPayload = { name: currentTestRun.name, projectKey: currentTestRun.projectKey, items: updatedItems }; response = await this.axiosInstance.put(`/rest/atm/1.0/testrun/${test_run_key}`, minimalPayload); method = 'PUT-minimal'; } catch (putError) { console.error('PUT minimal approach failed, trying POST approach...'); // Approach 2: Try POST to add test cases endpoint try { const postPayload = test_case_keys.map((testCaseKey) => ({ testCaseKey })); response = await this.axiosInstance.post(`/rest/atm/1.0/testrun/${test_run_key}/testcases`, postPayload); method = 'POST-testcases'; } catch (postError) { console.error('POST testcases approach failed, trying PUT with full payload...'); // Approach 3: Try PUT with full payload but clean read-only fields const fullPayload = { ...currentTestRun }; fullPayload.items = updatedItems; // Remove read-only fields delete fullPayload.key; delete fullPayload.createdOn; delete fullPayload.createdBy; delete fullPayload.executionTime; delete fullPayload.estimatedTime; delete fullPayload.testCaseCount; delete fullPayload.issueCount; delete fullPayload.executionSummary; delete fullPayload.status; response = await this.axiosInstance.put(`/rest/atm/1.0/testrun/${test_run_key}`, fullPayload); method = 'PUT-full'; } } if (response && (response.status === 200 || response.status === 201)) { return { content: [ { type: 'text', text: `✅ Successfully added ${uniqueNewItems.length} test cases to ${test_run_key} using ${method}:\n${JSON.stringify({ testRunKey: test_run_key, addedTestCases: test_case_keys, uniqueNewCases: uniqueNewItems.length, totalTestCases: updatedItems.length, method: method }, null, 2)}`, }, ], }; } else { throw new Error(`All update approaches failed. Last response status: ${response?.status || 'unknown'}`); } } catch (error) { let errorMessage = 'Unknown error'; if (error instanceof Error && 'response' in error) { const axiosError = error; if (axiosError.response?.status === 404) { errorMessage = `Test run ${test_run_key} not found`; } else { errorMessage = `Status: ${axiosError.response?.status}, Data: ${JSON.stringify(axiosError.response?.data)}`; } } else if (error instanceof Error) { errorMessage = error.message; } else { errorMessage = String(error); } throw new McpError(ErrorCode.InternalError, `Failed to add test cases to run: ${errorMessage}`); } } }