mcp-azdo
Version:
A command-line tool that provides a set of utilities to interact with Azure DevOps services, designed to be used as a Model Context Protocol (MCP) server. It allows you to manage test cases, test suites, and other work items.
870 lines • 69.3 kB
JavaScript
import { z } from "zod";
import axios from 'axios';
import { getAzureDevOpsConfig } from './configStore.js'; // Corrected import path
import { addItemToJIRA } from './jiraUtils.js'; // Import Jira functionality
// Helper function to format natural language steps into Azure DevOps XML
function formatStepsToAzdoXml(naturalLanguageSteps) {
function htmlEncode(str) {
return str.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
const stepLines = naturalLanguageSteps.split('\n').filter(line => line.trim() !== '');
let stepIdCounter = 1;
const stepXmls = [];
let maxStepId = 0;
for (const line of stepLines) {
const currentStepId = stepIdCounter++;
if (currentStepId > maxStepId) {
maxStepId = currentStepId;
}
let stepXml = '';
const encodedLine = htmlEncode(line);
const validateKeywords = ["verify", "ensure", "check", "expected:"];
const isValidateStep = validateKeywords.some(keyword => line.toLowerCase().includes(keyword));
if (isValidateStep) {
let actionPart = encodedLine;
let expectedPart = htmlEncode("Result is as expected.");
const expectedMarker = "expected:";
const expectedIndex = line.toLowerCase().indexOf(expectedMarker);
if (expectedIndex !== -1) {
actionPart = htmlEncode(line.substring(0, expectedIndex).trim());
expectedPart = htmlEncode(line.substring(expectedIndex + expectedMarker.length).trim());
}
else {
actionPart = encodedLine;
}
if (!expectedPart.trim() && expectedIndex !== -1) {
expectedPart = htmlEncode("Result is as expected.");
}
stepXml =
`<step id="${currentStepId}" type="ValidateStep">
<parameterizedString isformatted="true"><DIV><P>${actionPart}</P></DIV></parameterizedString>
<parameterizedString isformatted="true"><DIV><P>${expectedPart}</P></DIV></parameterizedString>
<description/>
</step>`;
}
else {
stepXml =
`<step id="${currentStepId}" type="ActionStep">
<parameterizedString isformatted="true"><DIV><P>${encodedLine}</P></DIV></parameterizedString>
<parameterizedString isformatted="true"><DIV><P><BR/></P></DIV></parameterizedString>
<description/>
</step>`;
}
stepXmls.push(stepXml);
}
if (stepXmls.length === 0) {
return "<steps id=\"0\" last=\"0\"></steps>";
}
const joinedStepXmls = stepXmls.join('\n ');
return `<steps id="0" last="${maxStepId}">\n ${joinedStepXmls}\n</steps>`;
}
/**
* Finds an existing static test suite by name under a parent suite or creates it if it doesn't exist.
* @param options - The options for finding or creating the test suite.
* @param options.planId - The ID of the Test Plan.
* @param options.parentSuiteId - The ID of the parent Test Suite.
* @param options.suiteName - The name of the suite to find or create.
* @returns A Promise that resolves to the ID of the found or created test suite.
* @throws An error if the suite cannot be found or created.
*/
async function getOrCreateStaticTestSuite(options) {
const { planId, parentSuiteId, suiteName } = options;
let configFromStore;
try {
configFromStore = await getAzureDevOpsConfig();
}
catch (err) {
// Propagate error if config is not set
throw new Error(`Azure DevOps configuration error: ${err.message}. Please ensure AZDO_ORG, AZDO_PROJECT, and AZDO_PAT environment variables are set.`);
}
const { organization, projectName, pat } = configFromStore; // Destructure pat here
// const listSuitesUrl = `https://dev.azure.com/${organization}/${projectName}/_apis/testplan/Plans/${planId}/Suites/${parentSuiteId}/suites?api-version=7.0`; // Corrected URL to list child suites
try {
// // Attempt to find an existing suite with the same name under the parent
// const listResponse = await axios.get(listSuitesUrl, {
// headers: {
// 'Authorization': `Bearer ${pat}`,
// 'Content-Type': 'application/json'
// }
// });
// if (listResponse.data && listResponse.data.value) {
// const existingSuite = listResponse.data.value.find((suite: any) => suite.name === suiteName && suite.suiteType === "StaticTestSuite");
// if (existingSuite) {
// console.log(`Found existing static suite '${suiteName}' with ID: ${existingSuite.id} under parent ${parentSuiteId}.`);
// return existingSuite.id;
// }
// }
// If not found, create a new suite
const createSuiteUrl = `https://dev.azure.com/${organization}/${projectName}/_apis/testplan/Plans/${planId}/suites?api-version=7.0`;
const createSuiteBody = {
suiteType: "StaticTestSuite", // Corrected: lowercase 's'
name: suiteName,
parentSuite: {
id: parentSuiteId
},
inheritDefaultConfigurations: true // Added as per documentation for new suites
};
const createResponse = await axios.post(createSuiteUrl, createSuiteBody, {
headers: {
'Authorization': `Bearer ${pat}`,
'Content-Type': 'application/json'
}
});
if (createResponse.data && createResponse.data.id) {
console.log(`Successfully created static suite '${suiteName}' with ID: ${createResponse.data.id}`);
return createResponse.data.id;
}
else {
throw new Error('Failed to create suite or extract ID from response.');
}
}
catch (error) {
// 2.5: Handle API responses and errors robustly
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// Check if the error is from getAzureDevOpsConfig (already handled by the throw above, but good for general robustness)
if (errorMessage.startsWith('Azure DevOps configuration error')) { // Updated error check
throw error; // Re-throw the specific config error
}
console.error(`Error in getOrCreateStaticTestSuite for suite '${suiteName}' in project '${projectName}':`, error);
const azdoError = error.response?.data?.message || errorMessage;
throw new Error(`Failed to find or create static suite '${suiteName}' under parent suite ${parentSuiteId}. Plan: ${planId}, Project: ${projectName}. Error: ${azdoError}`);
}
}
/**
* @description Updates an existing Azure DevOps Test Case work item with automated test details.
* @param options The options for updating the test case.
* @param options.testCaseId The ID of the Test Case work item to update.
* @param options.automatedTestName The fully qualified name of the automated test method (e.g., 'Namespace.ClassName.MethodName').
* @param options.automatedTestStorage The name of the test assembly or DLL (e.g., 'MyProject.Tests.dll').
* @returns A promise that resolves to an object indicating success or failure, along with response data or error details.
*/
export async function updateAutomatedTest(options) {
const { testCaseId, automatedTestName, automatedTestStorage } = options;
let config;
try {
config = await getAzureDevOpsConfig();
}
catch (err) {
return { success: false, message: `Azure DevOps configuration error: ${err.message}. Please ensure AZDO_ORG, AZDO_PROJECT, and AZDO_PAT environment variables are set.` };
}
const { organization, projectName, pat } = config; // Destructure pat here
const apiUrl = `https://dev.azure.com/${organization}/${projectName}/_apis/wit/workitems/${testCaseId}?api-version=7.1-preview.3`;
const requestBody = [
{ "op": "add", "path": "/fields/Microsoft.VSTS.TCM.AutomatedTestName", "value": automatedTestName },
{ "op": "add", "path": "/fields/Microsoft.VSTS.TCM.AutomatedTestStorage", "value": automatedTestStorage }
];
try {
console.log(`Attempting to update test case ${testCaseId} with automation details. URL: ${apiUrl}`);
const response = await axios.patch(apiUrl, requestBody, {
headers: {
'Authorization': `Bearer ${pat}`, // Use pat from config
'Content-Type': 'application/json-patch+json'
}
});
if (response.status === 200) {
console.log(`Test case ${testCaseId} updated successfully. Data:`, response.data);
return { success: true, message: `Test case ${testCaseId} updated successfully with automation details.`, data: response.data };
}
else {
// This case might not be hit if axios throws for non-2xx statuses.
console.warn(`Update for test case ${testCaseId} returned status ${response.status}`, response.data);
return { success: false, message: `Test case ${testCaseId} update returned status ${response.status}`, data: response.data };
}
}
catch (error) {
console.error(`Error updating test case ${testCaseId} with automation details:`, error.response?.data || error.message);
// Check if the error is due to config issues from a deeper call, though getAzureDevOpsConfig should catch it earlier
if (error.message.includes('Azure DevOps configuration error')) {
return { success: false, message: error.message };
}
return {
success: false,
message: `Error updating test case ${testCaseId}: ${error.message}`,
errorDetails: error.response?.data
};
}
}
// Schema for the update-automated-test tool
const UpdateAutomatedTestSchema = z.object({
testCaseId: z.number().describe("The ID of the Test Case work item to update."),
automatedTestName: z.string().describe("The fully qualified name of the automated test method (e.g., \'Namespace.ClassName.MethodName\')."),
automatedTestStorage: z.string().describe("The name of the test assembly or DLL (e.g., \'MyProject.Tests.dll\')."),
});
/**
* Registers a tool to create a static test suite in Azure DevOps.
* This is an MCP wrapper around the getOrCreateStaticTestSuite function.
*/
export function createStaticTestSuiteTool(server) {
server.tool("create-static-testsuite", "Creates a new Static Test Suite in Azure DevOps or finds an existing one with the same name. Requires AZDO_ORG, AZDO_PROJECT, and AZDO_PAT environment variables to be set.", // Updated description
{
planId: z.number().describe("The ID of the Test Plan."),
parentSuiteId: z.number().describe("The ID of the parent Test Suite."),
suiteName: z.string().describe("The name of the static test suite to create or find."),
}, async ({ planId, parentSuiteId, suiteName }) => {
try {
// Config (org, project, pat) is sourced within getOrCreateStaticTestSuite via getAzureDevOpsConfig
const suiteId = await getOrCreateStaticTestSuite({
planId,
parentSuiteId,
suiteName,
});
return {
content: [{
type: "text",
text: `Static test suite '${suiteName}' (ID: ${suiteId}) successfully created or found under parent suite ${parentSuiteId} in plan ${planId}.`
}]
};
}
catch (error) {
console.error('Error in create-static-testsuite tool:', error);
return {
content: [{
type: "text",
text: `Error creating or finding static test suite: ${error instanceof Error ? error.message : 'Unknown error'}. Ensure AZDO_ORG, AZDO_PROJECT, and AZDO_PAT environment variables are correctly set.` // Enhanced error message
}]
};
}
});
}
/**
* Helper function to add one or more test cases to a specified test suite via Azure DevOps API.
* @param options - The options for adding test cases.
* @param options.createCopy - When true, creates new copies of the test cases instead of references. Default is false.
* @returns A promise that resolves to an object indicating success or failure and a message.
*/
async function addTestCasesToSuiteAPI(options) {
const { organization, projectName, pat, planId, suiteId, testCaseIds, createCopy = false } = options;
if (!testCaseIds || testCaseIds.length === 0) {
return { success: true, message: "No test cases provided to add." };
}
// If createCopy is false, use the standard reference-based approach
if (!createCopy) {
// The API for bulk add to a suite expects POST to /_apis/testplan/Plans/{planId}/Suites/{suiteId}/testcases/{testCaseIds}
// where {testCaseIds} is a comma-separated string of IDs in the URL path.
const addTcToSuiteUrl = `https://dev.azure.com/${organization}/${projectName}/_apis/test/Plans/${planId}/Suites/${suiteId}/testcases/${testCaseIds}?api-version=7.0`;
try {
// Corrected: Send null as the body, headers in the config (3rd argument)
// The API takes test case IDs from the URL path, so no body is needed for this specific endpoint.
const response = await axios.post(addTcToSuiteUrl, null, {
headers: {
'Authorization': `Bearer ${pat}`,
'Content-Type': 'application/json' // This content type is standard, even if no body is sent.
}
});
// Assuming success if axios doesn't throw for non-2xx statuses.
// The response for this API call is usually an array of the test case references that were added.
// Example: response.data.value might contain the added items.
// Count can be derived from the input string as a fallback if response doesn't clearly state it.
const count = response.data?.count || response.data?.value?.length || testCaseIds.split(',').length;
const message = `Successfully added ${count} test case reference(s) (${testCaseIds}) to suite ${suiteId} in plan ${planId}.`;
console.log(message, response.data);
return { success: true, message: message, data: response.data };
}
catch (error) {
const errorMessage = error.message || 'Unknown error';
const azdoErrorDetail = error.response?.data?.message || '';
const fullErrorMessage = `Failed to add test case reference(s) (${testCaseIds}) to suite ${suiteId} in plan ${planId}: ${errorMessage}. ${azdoErrorDetail}`.trim();
console.error(fullErrorMessage, error.response?.data);
return {
success: false,
message: fullErrorMessage,
errorDetails: error.response?.data
};
}
}
// If createCopy is true, create copies of the test cases
else {
const testCaseIdsArray = testCaseIds.split(',');
const newTestCaseIds = [];
const errors = [];
console.log(`Creating ${testCaseIdsArray.length} new test case(s) as copies...`);
// Process each test case sequentially to avoid overwhelming the API
for (const sourceTestCaseId of testCaseIdsArray) {
try {
const result = await copyTestCaseAndAddToSuite({
organization,
projectName,
pat,
sourceTestCaseId,
planId,
suiteId
});
if (result.success && result.newTestCaseId) {
newTestCaseIds.push(result.newTestCaseId);
}
else {
errors.push(`Failed to copy test case ${sourceTestCaseId}: ${result.message}`);
}
}
catch (error) {
const errorMessage = error.message || 'Unknown error';
errors.push(`Error copying test case ${sourceTestCaseId}: ${errorMessage}`);
}
} // Generate response based on results
if (newTestCaseIds.length > 0) {
const successMessage = `Successfully created ${newTestCaseIds.length} new test case(s) as copies of (${testCaseIds}) and added them to suite ${suiteId} in plan ${planId}. New IDs: ${newTestCaseIds.join(', ')}.`;
if (errors.length > 0) {
return {
success: true,
message: `${successMessage} With some errors: ${errors.join('; ')}`,
newTestCaseIds
};
}
else {
return {
success: true,
message: successMessage,
newTestCaseIds
};
}
}
else {
return {
success: false,
message: `Failed to create any test case copies. Errors: ${errors.join('; ')}`
};
}
}
}
export function addTestCaseToTestSuiteTool(server) {
server.tool("add-testcase-to-testsuite", "Adds an existing test case to a specified test suite in Azure DevOps and optionally links it to a JIRA issue. Requires AZDO_ORG, AZDO_PROJECT, and AZDO_PAT environment variables to be set.", {
testCaseIdString: z.string().describe("The comma-delim string of ID of the Test Case."),
planId: z.number().describe("The ID of the Test Plan containing the suite."),
suiteId: z.number().describe("The ID of the Test Suite to add the test case to."),
jiraWorkItemId: z.string().optional().describe("Optional. The JIRA issue ID to link the test case(s) to."),
createCopy: z.boolean().optional().default(true).describe("Optional. When true, creates new copies of the test cases instead of references.")
}, async ({ testCaseIdString, planId, suiteId, jiraWorkItemId, createCopy }) => {
let config;
try {
config = await getAzureDevOpsConfig();
}
catch (err) {
return { content: [{ type: "text", text: `Azure DevOps configuration error: ${err.message}. Please ensure AZDO_ORG, AZDO_PROJECT, and AZDO_PAT environment variables are set.` }] };
}
const { organization, projectName, pat } = config;
const trimmedTestCaseIdString = testCaseIdString.trim();
if (!trimmedTestCaseIdString) {
return { content: [{ type: "text", text: "No valid test case IDs provided in the string." }] };
} // Log what mode we're using
if (createCopy) {
console.log(`Adding test case(s) to suite ${suiteId} as new copies`);
}
else {
console.log(`Adding test case(s) to suite ${suiteId} as references`);
}
const result = await addTestCasesToSuiteAPI({
organization,
projectName,
pat,
planId,
suiteId,
testCaseIds: trimmedTestCaseIdString,
createCopy
});
if (!result.success) {
const errorMode = createCopy ? "copying test cases" : "adding test case references";
return {
content: [{
type: "text",
text: `Error while ${errorMode}: ${result.message}`
}]
};
}
// If JIRA work item ID is provided, add links to JIRA
if (jiraWorkItemId) {
const testCaseIds = trimmedTestCaseIdString.split(',');
return handleJiraIntegrationForCopiedTestCases(jiraWorkItemId, testCaseIds, organization, projectName, pat, result.message // Pass the success message from addTestCasesToSuiteAPI
);
}
return {
content: [{
type: "text",
text: result.message
}]
};
});
}
export function registerTestCaseTool(server) {
server.tool("create-testcase", "Creates a new Test Case work item in Azure DevOps and optionally links it to a JIRA issue. Requires AZDO_ORG, AZDO_PROJECT, and AZDO_PAT environment variables to be set.", // Updated description
{
title: z.string().describe("The title of the test case."),
areaPath: z.string().optional().default("Health").describe("The Area Path for the test case (e.g., 'MyProject\\Area\\Feature'). Defaults to the project name if not specified, but 'Health' is a common default."),
iterationPath: z.string().optional().default("Health").describe("The Iteration Path for the test case (e.g., 'MyProject\\Sprint 1'). Defaults to the project name if not specified, but 'Health' is a common default."),
steps: z.string().optional().default("").describe("Multi-line natural language string describing test steps. Each line can be an action or a validation. For validations, use 'Expected:' to denote the expected outcome."),
priority: z.number().optional().default(2).describe("Priority of the test case (1=High, 2=Medium, 3=Low, 4=Very Low). Defaults to 2."),
assignedTo: z.string().optional().describe("The unique name or email of the user to assign the test case to (e.g., 'user@example.com'). Optional."),
state: z.string().optional().default("Design").describe("The initial state of the test case (e.g., 'Design', 'Ready'). Defaults to 'Design'."),
reason: z.string().optional().default("New").describe("The reason for the initial state (e.g., 'New', 'Test Case created'). Defaults to 'New'."),
automationStatus: z.string().optional().default("Not Automated").describe("The automation status of the test case (e.g., 'Not Automated', 'Automated', 'Planned'). Defaults to 'Not Automated'."),
parentPlanId: z.number().optional().describe("Optional. The ID of the Test Plan. If provided with `parentSuiteId`, a new child test suite (named after the test case title) will be created under the specified `parentSuiteId`, and the test case will be added to this new child suite."),
parentSuiteId: z.number().optional().describe("Optional. The ID of the parent Test Suite. If provided with `parentPlanId`, a new child test suite (named after the test case title) will be created under this suite, and the test case will be added to this new child suite."),
jiraWorkItemId: z.string().optional().default("").describe("Optional. The JIRA issue ID to link the test case to."),
createTestSuite: z.boolean().optional().default(true).describe("Optional. When false, the test case will be added directly to the parentSuiteId instead of creating a new child suite. Default is true.")
}, async ({ title, areaPath, iterationPath, steps, priority, assignedTo, state, reason, automationStatus, parentPlanId, parentSuiteId, jiraWorkItemId, createTestSuite }) => {
let config;
try {
config = await getAzureDevOpsConfig(); // Get config (includes pat)
}
catch (err) {
return { content: [{ type: "text", text: `Azure DevOps configuration error: ${err.message}. Please ensure AZDO_ORG, AZDO_PROJECT, and AZDO_PAT environment variables are set.` }] };
}
const { organization, projectName, pat } = config; // Destructure pat
// Adjust default for areaPath and iterationPath if they are "Health" and projectName is available
const effectiveAreaPath = (areaPath === "Health" && projectName) ? projectName : areaPath;
const effectiveIterationPath = (iterationPath === "Health" && projectName) ? projectName : iterationPath;
const apiUrl = `https://dev.azure.com/${organization}/${projectName}/_apis/wit/workitems/$Test%20Case?api-version=7.1-preview.3`;
const formattedStepsXml = formatStepsToAzdoXml(steps);
const requestBody = [
{
"op": "add",
"path": "/fields/System.Title",
"value": jiraWorkItemId.length > 0 ? `${jiraWorkItemId} - ${title}` : title
},
{
"op": "add",
"path": "/fields/System.AreaPath",
"value": effectiveAreaPath // Use effectiveAreaPath
},
{
"op": "add",
"path": "/fields/System.IterationPath",
"value": effectiveIterationPath // Use effectiveIterationPath
},
{
"op": "add",
"path": "/fields/Microsoft.VSTS.TCM.Steps",
"value": formattedStepsXml
},
{
"op": "add",
"path": "/fields/Microsoft.VSTS.Common.Priority",
"value": priority
},
{
"op": "add",
"path": "/fields/System.State",
"value": state
},
{
"op": "add",
"path": "/fields/System.Reason",
"value": reason
},
{
"op": "add",
"path": "/fields/Microsoft.VSTS.TCM.AutomationStatus",
"value": automationStatus
}
];
if (assignedTo) {
requestBody.push({
"op": "add",
"path": "/fields/System.AssignedTo",
"value": assignedTo
});
}
try {
const response = await axios.post(apiUrl, requestBody, {
headers: {
'Authorization': `Bearer ${pat}`, // Use pat from config
'Content-Type': 'application/json-patch+json'
}
});
const createdTestCaseId = response.data.id;
let messageParts = [];
messageParts.push(`Test Case ${createdTestCaseId} created successfully.`);
const testCaseUrl = response.data._links?.html?.href;
if (testCaseUrl) {
messageParts.push(`View at: ${testCaseUrl}.`);
}
// If JIRA work item ID is provided, add link to JIRA and update test case description
if (jiraWorkItemId && testCaseUrl) {
try {
// First update the test case description with a Jira link
try {
const updateResult = await updateTestCase({ testCaseId: createdTestCaseId, jiraKey: jiraWorkItemId });
messageParts.push(updateResult.success ?
`Successfully updated Test Case ${createdTestCaseId} description with link to JIRA issue ${jiraWorkItemId}.` :
`Warning - Failed to update Test Case ${createdTestCaseId} description: ${updateResult.message}`);
}
catch (updateError) {
const updateErrorMessage = updateError instanceof Error ? updateError.message : 'Unknown error';
messageParts.push(`Warning - Error updating Test Case ${createdTestCaseId} description: ${updateErrorMessage}`);
}
// Then add the test case link to the JIRA item (original logic)
const jiraResult = await addItemToJIRA(jiraWorkItemId, [{
text: title,
url: testCaseUrl
}]);
messageParts.push(jiraResult.success ?
`Successfully added Test Case link to JIRA issue ${jiraWorkItemId}.` :
`Warning - JIRA update failed: ${jiraResult.message}`);
}
catch (jiraError) {
const jiraErrorMessage = jiraError instanceof Error ? jiraError.message : 'Unknown error';
messageParts.push(`Warning - Failed to link with JIRA issue ${jiraWorkItemId}: ${jiraErrorMessage}`);
}
} // Logic for handling test suite creation and adding the test case to the appropriate suite
if (parentPlanId && parentPlanId !== 0 && parentSuiteId && parentSuiteId !== 0) {
let suiteOperationMessage = "";
// Check if we should create a child test suite or add directly to parent suite
if (createTestSuite) {
// Create a child suite and add the test case to it
let newlyCreatedChildSuiteId;
const childSuiteName = title;
try {
newlyCreatedChildSuiteId = await getOrCreateStaticTestSuite({
planId: parentPlanId,
parentSuiteId: parentSuiteId,
suiteName: childSuiteName,
});
suiteOperationMessage = `Child suite '${childSuiteName}' (ID: ${newlyCreatedChildSuiteId}) ensured under parent suite ${parentSuiteId}.`;
if (newlyCreatedChildSuiteId) {
try {
// Add the created test case to the NEWLY CREATED CHILD suite
const addChildTcToSuiteUrl = `https://dev.azure.com/${organization}/${projectName}/_apis/test/Plans/${parentPlanId}/Suites/${newlyCreatedChildSuiteId}/testcases/${createdTestCaseId}?api-version=7.0`;
const addChildTcToSuiteBody = [{ id: createdTestCaseId.toString() }];
await axios.post(addChildTcToSuiteUrl, addChildTcToSuiteBody, {
headers: {
'Authorization': `Bearer ${pat}`,
'Content-Type': 'application/json'
}
});
suiteOperationMessage += ` Test case ${createdTestCaseId} added to child suite ${newlyCreatedChildSuiteId}.`;
}
catch (addChildError) {
const addChildErrorMessage = addChildError instanceof Error ? addChildError.message : 'Unknown error';
suiteOperationMessage += ` Failed to add test case ${createdTestCaseId} to child suite ${newlyCreatedChildSuiteId}: ${addChildErrorMessage}.`;
}
}
}
catch (suiteError) {
const suiteErrorMessage = suiteError instanceof Error ? suiteError.message : 'Unknown error';
suiteOperationMessage = `Failed to create/retrieve child suite '${childSuiteName}' under parent suite ${parentSuiteId}: ${suiteErrorMessage}. Test case ${createdTestCaseId} not added to any new child suite.`;
}
}
else {
// Add the test case directly to the parent suite
try {
const addToParentSuiteUrl = `https://dev.azure.com/${organization}/${projectName}/_apis/test/Plans/${parentPlanId}/Suites/${parentSuiteId}/testcases/${createdTestCaseId}?api-version=7.0`;
const addToParentSuiteBody = [{ id: createdTestCaseId.toString() }];
await axios.post(addToParentSuiteUrl, addToParentSuiteBody, {
headers: {
'Authorization': `Bearer ${pat}`,
'Content-Type': 'application/json'
}
});
suiteOperationMessage = `Test case ${createdTestCaseId} added directly to parent suite ${parentSuiteId}.`;
}
catch (addParentError) {
const addParentErrorMessage = addParentError instanceof Error ? addParentError.message : 'Unknown error';
suiteOperationMessage = `Failed to add test case ${createdTestCaseId} to parent suite ${parentSuiteId}: ${addParentErrorMessage}.`;
}
}
if (suiteOperationMessage) {
messageParts.push(suiteOperationMessage);
}
}
return {
content: [{
type: "text",
text: messageParts.join(' ')
}]
};
}
catch (error) {
console.error('Error creating test case in Azure DevOps:', error);
// Check if the error is due to config issues from a deeper call
if (error.message.includes('Azure DevOps configuration error') || error.message.includes('AZDO_PAT')) {
return { content: [{ type: "text", text: error.message }] };
}
return {
content: [{
type: "text",
text: `Error creating test case: ${error instanceof Error ? error.message : 'Unknown error'}. Ensure AZDO_ORG, AZDO_PROJECT, and AZDO_PAT environment variables are correctly set.` // Enhanced error message
}]
};
}
});
}
/**
* Registers the 'update-automated-test' tool with the MCP server.
* This tool updates an existing Azure DevOps Test Case work item with automated test details.
* Requires AZDO_ORG, AZDO_PROJECT, and AZDO_PAT environment variables to be set.
*/
export function updateAutomatedTestTool(server) {
server.tool("update-automated-test", "Updates an Azure DevOps Test Case with automated test details (e.g., linking to an automated test method and assembly). Requires AZDO_ORG, AZDO_PROJECT, and AZDO_PAT environment variables to be set.", // Updated description
UpdateAutomatedTestSchema.shape, async (params) => {
try {
// Config (org, pat) is sourced within updateAutomatedTest via getAzureDevOpsConfig
const result = await updateAutomatedTest(params);
if (result.success) {
let message = result.message;
if (result.data?._links?.html?.href) {
message += ` View at: ${result.data._links.html.href}`;
}
return { content: [{ type: "text", text: message }] };
}
else {
return { content: [{ type: "text", text: `Error: ${result.message}${result.errorDetails ? ' Details: ' + JSON.stringify(result.errorDetails) : ''}` }] };
}
}
catch (error) {
console.error('Error in update-automated-test tool:', error);
// Check if the error is due to config issues from a deeper call
if (error.message.includes('Azure DevOps configuration error') || error.message.includes('AZDO_PAT')) {
return { content: [{ type: "text", text: error.message }] };
}
return {
content: [{
type: "text",
text: `Unhandled error in update-automated-test tool: ${error instanceof Error ? error.message : 'Unknown error'}. Ensure AZDO_ORG, AZDO_PROJECT, and AZDO_PAT environment variables are correctly set.` // Enhanced error message
}]
};
}
});
}
export function copyTestCasesToTestSuiteTool(server) {
server.tool("copy-testcases-to-testsuite", "Copies all test cases from a specified source test suite to a new test suite (created with the same name as the source suite) under a specified destination test plan and parent suite. Requires AZDO_ORG, AZDO_PROJECT, and AZDO_PAT environment variables to be set.", {
sourcePlanId: z.number().describe("ID of the source Test Plan containing the suite from which to copy test cases."),
sourceSuiteId: z.number().describe("ID of the source Test Suite from which to copy test cases."),
destinationPlanId: z.number().describe("ID of the destination Test Plan where the new suite will be created."),
destinationSuiteId: z.number().describe("ID of the parent Test Suite in the destination plan under which the new suite (containing copied test cases) will be created."),
jiraWorkItemId: z.string().optional().describe("Optional. The JIRA issue ID to link the copied test cases to."),
createCopy: z.boolean().optional().default(true).describe("Optional. When true, creates new copies of the test cases instead of references."),
createTestSuite: z.boolean().optional().default(true).describe("Optional. When false, the test case will be added directly to the parentSuiteId instead of creating a new child suite. Default is true.")
}, async ({ sourcePlanId, sourceSuiteId, destinationPlanId, destinationSuiteId, jiraWorkItemId, createCopy, createTestSuite }) => {
try {
// Validate JIRA ID format if provided
// isValidJiraId is defined later in the file, this should be fine at runtime
if (jiraWorkItemId && !isValidJiraId(jiraWorkItemId)) {
return {
content: [{
type: "text",
text: `Invalid JIRA issue ID format: ${jiraWorkItemId}. Expected format is like PROJECT-123.`
}]
};
}
const { organization, projectName, pat } = await getAzureDevOpsConfig();
let sourceTestCaseIds = [];
// Fetch test cases from source suite
try {
const getTestCasesUrl = `https://dev.azure.com/${organization}/${projectName}/_apis/test/Plans/${sourcePlanId}/Suites/${sourceSuiteId}/testcases?api-version=7.0`;
console.log(`Fetching test cases from URL: ${getTestCasesUrl}`);
const response = await axios.get(getTestCasesUrl, {
headers: {
'Authorization': `Bearer ${pat}`,
'Content-Type': 'application/json'
}
});
if (response.data && response.data.value && response.data.value.length > 0) {
sourceTestCaseIds = response.data.value.map((item) => item.testCase.id.toString());
console.log(`Found ${sourceTestCaseIds.length} test case(s) in source suite ${sourceSuiteId}.`);
}
else {
console.log(`No test cases found in source suite ${sourceSuiteId}.`);
return {
content: [{ type: "text", text: `No test cases found in source suite ${sourceSuiteId} (Plan: ${sourcePlanId}). No test cases will be copied.` }]
};
}
}
catch (error) {
console.error(`Error fetching test cases from source suite ${sourceSuiteId}:`, error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const azdoErrorDetail = error.response?.data?.message ? `Azure DevOps Error: ${error.response.data.message}` : '';
return {
content: [{ type: "text", text: `Error fetching test cases from source suite ${sourceSuiteId} (Plan: ${sourcePlanId}): ${errorMessage}. ${azdoErrorDetail}`.trim() }]
};
}
if (sourceTestCaseIds.length === 0) {
return {
content: [{ type: "text", text: `No test cases found in source suite ${sourceSuiteId} (Plan: ${sourcePlanId}). No test cases were copied.` }]
};
}
// Fetch Source Suite Name
let sourceSuiteName = '';
try {
const getSourceSuiteDetailsUrl = `https://dev.azure.com/${organization}/${projectName}/_apis/testplan/Plans/${sourcePlanId}/Suites/${sourceSuiteId}?api-version=7.0`;
console.log(`Fetching source suite details from URL: ${getSourceSuiteDetailsUrl}`);
const suiteDetailsResponse = await axios.get(getSourceSuiteDetailsUrl, {
headers: {
'Authorization': `Bearer ${pat}`,
'Content-Type': 'application/json'
}
});
if (suiteDetailsResponse.data && suiteDetailsResponse.data.name) {
sourceSuiteName = suiteDetailsResponse.data.name;
console.log(`Source suite name: '${sourceSuiteName}'`);
}
else {
throw new Error("Source suite name could not be retrieved from API response.");
}
}
catch (error) {
console.error(`Error fetching details for source suite ${sourceSuiteId}:`, error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const azdoErrorDetail = error.response?.data?.message ? `Azure DevOps Error: ${error.response.data.message}` : '';
return {
content: [{ type: "text", text: `Error fetching details for source suite ${sourceSuiteId} (Plan: ${sourcePlanId}): ${errorMessage}. ${azdoErrorDetail}`.trim() }]
};
} // Determine target suite ID based on createTestSuite parameter
let targetSuiteId;
let suiteOperationMessage = '';
if (createTestSuite) {
// Create/Get Child Destination Test Suite with source suite name
try {
console.log(`Attempting to create/get destination suite with name '${sourceSuiteName}' under parent suite ${destinationSuiteId} in plan ${destinationPlanId}.`);
targetSuiteId = await getOrCreateStaticTestSuite({
planId: destinationPlanId,
parentSuiteId: destinationSuiteId,
suiteName: sourceSuiteName
});
console.log(`Ensured destination suite '${sourceSuiteName}' (ID: ${targetSuiteId}) exists.`);
suiteOperationMessage = `New/existing suite '${sourceSuiteName}' (ID: ${targetSuiteId}) under parent ${destinationSuiteId}`;
}
catch (error) {
console.error(`Error creating/finding destination suite '${sourceSuiteName}':`, error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return {
content: [{ type: "text", text: `Error creating or finding destination suite '${sourceSuiteName}' under parent ${destinationSuiteId} in plan ${destinationPlanId}: ${errorMessage}` }]
};
}
}
else {
// Use destination suite ID directly
targetSuiteId = destinationSuiteId;
console.log(`Using destination suite ${destinationSuiteId} directly as target (createTestSuite=false)`);
suiteOperationMessage = `Parent suite (ID: ${targetSuiteId})`;
}
// Add Test Cases to Target Suite and handle JIRA integration
try {
const addResult = await addTestCasesToSuiteAPI({
organization,
projectName,
pat,
planId: destinationPlanId,
suiteId: targetSuiteId,
testCaseIds: sourceTestCaseIds.join(','),
createCopy
});
if (addResult.success) {
const successMessage = `Successfully copied ${sourceTestCaseIds.length} test case(s) from source suite '${sourceSuiteName}' (ID: ${sourceSuiteId}, Plan: ${sourcePlanId}) to ${createTestSuite ? `new/existing suite '${sourceSuiteName}'` : `parent suite`} (ID: ${targetSuiteId}, Plan: ${destinationPlanId}). Test Case IDs: ${sourceTestCaseIds.join(', ')}. API Response: ${addResult.message}`;
console.log(successMessage);
// Handle JIRA integration if workItemId is provided
if (jiraWorkItemId) {
return handleJiraIntegrationForCopiedTestCases(jiraWorkItemId, sourceTestCaseIds, organization, projectName, pat, successMessage);
}
return { content: [{ type: "text", text: successMessage }] };
}
else {
return {
content: [{ type: "text", text: `Error during copy operation after creating suite. Failed to add test cases to ${createTestSuite ? `destination suite '${sourceSuiteName}'` : `parent suite`} (ID: ${targetSuiteId}): ${addResult.message}` }]
};
}
}
catch (error) {
console.error(`Unexpected error in Step 5 when trying to add test cases to ${createTestSuite ? `destination suite` : `parent suite`} ${targetSuiteId}:`, error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return {
content: [{ type: "text", text: `Unexpected error adding test cases to ${createTestSuite ? `destination suite '${sourceSuiteName}'` : `parent suite`} (ID: ${targetSuiteId}): ${errorMessage}` }]
};
}
}
catch (err) {
if (err instanceof Error && (err.message.includes('Azure DevOps configuration error') || err.message.includes('AZDO_ORG') || err.message.includes('AZDO_PROJECT') || err.message.includes('AZDO_PAT'))) {
return { content: [{ type: "text", text: `Azure DevOps configuration error: ${err.message}` }] };
}
return { content: [{ type: "text", text: `An unexpected error occurred: ${err.message}` }] };
}
});
}
/**
* Retrieves all test cases from a test suite within a plan
* @param params Object containing planId and suiteId
* @returns A promise that resolves to the list of test cases
*/
async function getTestCasesFromSuites({ planId, suiteId }) {
const { organization, projectName, pat } = await getAzureDevOpsConfig();
try {
// Construct the API URL for getting test cases from a suite
const apiUrl = `https://dev.azure.com/${organization}/${projectName}/_apis/testplan/Plans/${planId}/Suites/${suiteId}/TestCase?isRecursive=true&api-version=7.2-preview.3`;
const response = await axios.get(apiUrl, {
headers: {
'Authorization': `Bearer ${pat}`,
'Content-Type': 'application/json'
}
});
// Return the test cases from the response
return {
success: true,
testCases: response.data.value || [],
message: `Retrieved ${response.data.value?.length || 0} test case(s) from suite ${suiteId} in plan ${planId}`
};
}
catch (error) {
console.error('Error retrieving test cases from suite:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const azdoErrorDetail = axios.isAxiosError(error) && error.response?.data?.message
? `Azure DevOps API Error: ${error.response.data.message}`
: '';
return {
success: false,
testCases: [],
message: `Failed to retrieve test cases from suite ${suiteId} in plan ${planId}. Error: ${errorMessage}. ${azdoErrorDetail}`.trim()
};
}
}
/**
* Retrieves child test suites for a specific test suite within a plan
* @param params Object containing planId and suiteId
* @returns A promise that resolves to the list of child test suites
*/
async function getChildTestSuites({ planId, suiteId }) {
const { organization, projectName, pat } = await getAzureDevOpsConfig();
try {
// First, get all suites for the plan with the tree structure
const apiUrl = `https://dev.azure.com/${organization}/${projectName}/_apis/testplan/Plans/${planId}/suites?asTreeView=true&api-version=7.2-preview.1`;
const response = await axios.get(apiUrl, {
headers: {
'Authorization': `Bearer ${pat}`,
'Content-Type': 'application/json'
}
}); // Process the response to find the specific suite and its children
let targetSuite = null;
// Define a recursive function to find the suite by ID
const findSuite = (suites) => {
if (!suites || !Array.isArray(suites))
return null;
for (const suite of suites) {
if (suite.id === suiteId) {
return suite;
}
if (suite.children) {
const found = findSuite(suite.children);
if (found)
return found;
}
}
return null;
};
// Find the target suite in the tree
if (response.data.value && Array.isArray(response.data.value)) {
targetSuite = findSuite(response.data.value);
}
if (!targetSuite) {
return {
success: false,
childSuites: [],
message: `Could not find test suite with ID ${suiteId} in plan ${planId}`
};
}
// Return the children of the target suite
const childSuites = targetSuite.children || [];
return {
success: true,
childSuites,
message: `R