@radholm/azure-devops-mcp 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,523 @@
1
+ // Copyright (c) Microsoft Corporation.
2
+ // Licensed under the MIT License.
3
+ import { z } from "zod";
4
+ import { apiVersion } from "../utils.js";
5
+ const Test_Plan_Tools = {
6
+ create_test_plan: "testplan_create_test_plan",
7
+ create_test_case: "testplan_create_test_case",
8
+ update_test_case_steps: "testplan_update_test_case_steps",
9
+ add_test_cases_to_suite: "testplan_add_test_cases_to_suite",
10
+ test_results_from_build_id: "testplan_show_test_results_from_build_id",
11
+ list_test_cases: "testplan_list_test_cases",
12
+ list_test_plans: "testplan_list_test_plans",
13
+ list_test_suites: "testplan_list_test_suites",
14
+ create_test_suite: "testplan_create_test_suite",
15
+ };
16
+ function configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider) {
17
+ server.tool(Test_Plan_Tools.list_test_plans, "Retrieve a paginated list of test plans from an Azure DevOps project. Allows filtering for active plans and toggling detailed information.", {
18
+ project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
19
+ filterActivePlans: z.boolean().default(true).describe("Filter to include only active test plans. Defaults to true."),
20
+ includePlanDetails: z.boolean().default(false).describe("Include detailed information about each test plan."),
21
+ continuationToken: z.string().optional().describe("Token to continue fetching test plans from a previous request."),
22
+ }, async ({ project, filterActivePlans, includePlanDetails, continuationToken }) => {
23
+ try {
24
+ const connection = await connectionProvider();
25
+ const accessToken = await tokenProvider();
26
+ const params = new URLSearchParams({ "api-version": apiVersion });
27
+ if (filterActivePlans)
28
+ params.append("filterActivePlans", "true");
29
+ if (includePlanDetails)
30
+ params.append("includePlanDetails", "true");
31
+ if (continuationToken)
32
+ params.append("continuationToken", continuationToken);
33
+ const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans?${params.toString()}`;
34
+ const headers = {
35
+ Authorization: `Bearer ${accessToken}`,
36
+ };
37
+ const userAgent = userAgentProvider?.();
38
+ if (userAgent) {
39
+ headers["User-Agent"] = userAgent;
40
+ }
41
+ const response = await fetch(url, {
42
+ method: "GET",
43
+ headers,
44
+ });
45
+ if (!response.ok) {
46
+ const errorText = await response.text();
47
+ throw new Error(`Failed to list test plans (${response.status}): ${errorText}`);
48
+ }
49
+ const body = await response.json();
50
+ const testPlans = body.value ?? [];
51
+ const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
52
+ const result = {
53
+ testPlans: testPlans,
54
+ };
55
+ if (nextToken) {
56
+ result.continuationToken = nextToken;
57
+ }
58
+ return {
59
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
60
+ };
61
+ }
62
+ catch (error) {
63
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
64
+ return {
65
+ content: [{ type: "text", text: `Error listing test plans: ${errorMessage}` }],
66
+ isError: true,
67
+ };
68
+ }
69
+ });
70
+ server.tool(Test_Plan_Tools.create_test_plan, "Creates a new test plan in the project.", {
71
+ project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project where the test plan will be created."),
72
+ name: z.string().describe("The name of the test plan to be created."),
73
+ iteration: z.string().describe("The iteration path for the test plan"),
74
+ description: z.string().optional().describe("The description of the test plan"),
75
+ startDate: z.string().optional().describe("The start date of the test plan"),
76
+ endDate: z.string().optional().describe("The end date of the test plan"),
77
+ areaPath: z.string().optional().describe("The area path for the test plan"),
78
+ }, async ({ project, name, iteration, description, startDate, endDate, areaPath }) => {
79
+ try {
80
+ const connection = await connectionProvider();
81
+ const testPlanApi = await connection.getTestPlanApi();
82
+ const testPlanToCreate = {
83
+ name,
84
+ iteration,
85
+ description,
86
+ startDate: startDate ? new Date(startDate) : undefined,
87
+ endDate: endDate ? new Date(endDate) : undefined,
88
+ areaPath,
89
+ };
90
+ const createdTestPlan = await testPlanApi.createTestPlan(testPlanToCreate, project);
91
+ return {
92
+ content: [{ type: "text", text: JSON.stringify(createdTestPlan, null, 2) }],
93
+ };
94
+ }
95
+ catch (error) {
96
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
97
+ return {
98
+ content: [{ type: "text", text: `Error creating test plan: ${errorMessage}` }],
99
+ isError: true,
100
+ };
101
+ }
102
+ });
103
+ server.tool(Test_Plan_Tools.create_test_suite, "Creates a new test suite in a test plan.", {
104
+ project: z.string().describe("Project ID or project name"),
105
+ planId: z.coerce.number().min(1).describe("ID of the test plan that contains the suites"),
106
+ parentSuiteId: z.coerce.number().min(1).describe("ID of the parent suite under which the new suite will be created, if not given by user this can be id of a root suite of the test plan"),
107
+ name: z.string().describe("Name of the child test suite"),
108
+ }, async ({ project, planId, parentSuiteId, name }) => {
109
+ const maxRetries = 5;
110
+ const baseDelay = 500; // milliseconds
111
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
112
+ try {
113
+ const connection = await connectionProvider();
114
+ const testPlanApi = await connection.getTestPlanApi();
115
+ const testSuiteToCreate = {
116
+ name,
117
+ parentSuite: {
118
+ id: parentSuiteId,
119
+ name: "",
120
+ },
121
+ suiteType: 2,
122
+ };
123
+ const createdTestSuite = await testPlanApi.createTestSuite(testSuiteToCreate, project, planId);
124
+ return {
125
+ content: [{ type: "text", text: JSON.stringify(createdTestSuite, null, 2) }],
126
+ };
127
+ }
128
+ catch (error) {
129
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
130
+ // Check if it's a concurrency conflict error
131
+ const isConcurrencyError = errorMessage.includes("TF26071") || errorMessage.includes("got update") || errorMessage.includes("changed by someone else");
132
+ // If it's a concurrency error and we have retries left, wait and retry
133
+ if (isConcurrencyError && attempt < maxRetries) {
134
+ const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 200; // Exponential backoff with jitter
135
+ await new Promise((resolve) => setTimeout(resolve, delay));
136
+ continue; // Retry
137
+ }
138
+ // If not a concurrency error or out of retries, return error
139
+ return {
140
+ content: [{ type: "text", text: `Error creating test suite: ${errorMessage}` }],
141
+ isError: true,
142
+ };
143
+ }
144
+ }
145
+ // This should never be reached, but TypeScript requires a return value
146
+ return {
147
+ content: [{ type: "text", text: "Error creating test suite: Maximum retries exceeded" }],
148
+ isError: true,
149
+ };
150
+ });
151
+ server.tool(Test_Plan_Tools.add_test_cases_to_suite, "Adds existing test cases to a test suite.", {
152
+ project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
153
+ planId: z.coerce.number().min(1).describe("The ID of the test plan."),
154
+ suiteId: z.coerce.number().min(1).describe("The ID of the test suite."),
155
+ testCaseIds: z.string().or(z.array(z.string())).describe("The ID(s) of the test case(s) to add. "),
156
+ }, async ({ project, planId, suiteId, testCaseIds }) => {
157
+ try {
158
+ const connection = await connectionProvider();
159
+ const testApi = await connection.getTestApi();
160
+ // If testCaseIds is an array, convert it to comma-separated string
161
+ const testCaseIdsString = Array.isArray(testCaseIds) ? testCaseIds.join(",") : testCaseIds;
162
+ const addedTestCases = await testApi.addTestCasesToSuite(project, planId, suiteId, testCaseIdsString);
163
+ return {
164
+ content: [{ type: "text", text: JSON.stringify(addedTestCases, null, 2) }],
165
+ };
166
+ }
167
+ catch (error) {
168
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
169
+ return {
170
+ content: [{ type: "text", text: `Error adding test cases to suite: ${errorMessage}` }],
171
+ isError: true,
172
+ };
173
+ }
174
+ });
175
+ server.tool(Test_Plan_Tools.create_test_case, "Creates a new test case work item.", {
176
+ project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
177
+ title: z.string().describe("The title of the test case."),
178
+ steps: z
179
+ .string()
180
+ .optional()
181
+ .describe("The steps to reproduce the test case. Make sure to format each step as '1. Step one|Expected result one\n2. Step two|Expected result two. USE '|' as the delimiter between step and expected result. DO NOT use '|' in the description of the step or expected result."),
182
+ priority: z.coerce.number().optional().describe("The priority of the test case."),
183
+ areaPath: z.string().optional().describe("The area path for the test case."),
184
+ iterationPath: z.string().optional().describe("The iteration path for the test case."),
185
+ testsWorkItemId: z.coerce.number().min(1).optional().describe("Optional work item id that will be set as a Microsoft.VSTS.Common.TestedBy-Reverse link to the test case."),
186
+ }, async ({ project, title, steps, priority, areaPath, iterationPath, testsWorkItemId }) => {
187
+ try {
188
+ const connection = await connectionProvider();
189
+ const witClient = await connection.getWorkItemTrackingApi();
190
+ let stepsXml;
191
+ if (steps) {
192
+ stepsXml = convertStepsToXml(steps);
193
+ }
194
+ // Create JSON patch document for work item
195
+ const patchDocument = [];
196
+ patchDocument.push({
197
+ op: "add",
198
+ path: "/fields/System.Title",
199
+ value: title,
200
+ });
201
+ if (testsWorkItemId) {
202
+ patchDocument.push({
203
+ op: "add",
204
+ path: "/relations/-",
205
+ value: {
206
+ rel: "Microsoft.VSTS.Common.TestedBy-Reverse",
207
+ url: `${connection.serverUrl}/${project}/_apis/wit/workItems/${testsWorkItemId}`,
208
+ },
209
+ });
210
+ }
211
+ if (stepsXml) {
212
+ patchDocument.push({
213
+ op: "add",
214
+ path: "/fields/Microsoft.VSTS.TCM.Steps",
215
+ value: stepsXml,
216
+ });
217
+ }
218
+ if (priority) {
219
+ patchDocument.push({
220
+ op: "add",
221
+ path: "/fields/Microsoft.VSTS.Common.Priority",
222
+ value: priority,
223
+ });
224
+ }
225
+ if (areaPath) {
226
+ patchDocument.push({
227
+ op: "add",
228
+ path: "/fields/System.AreaPath",
229
+ value: areaPath,
230
+ });
231
+ }
232
+ if (iterationPath) {
233
+ patchDocument.push({
234
+ op: "add",
235
+ path: "/fields/System.IterationPath",
236
+ value: iterationPath,
237
+ });
238
+ }
239
+ const workItem = await witClient.createWorkItem({}, patchDocument, project, "Test Case");
240
+ return {
241
+ content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }],
242
+ };
243
+ }
244
+ catch (error) {
245
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
246
+ return {
247
+ content: [{ type: "text", text: `Error creating test case: ${errorMessage}` }],
248
+ isError: true,
249
+ };
250
+ }
251
+ });
252
+ server.tool(Test_Plan_Tools.update_test_case_steps, "Update an existing test case work item.", {
253
+ id: z.coerce.number().min(1).describe("The ID of the test case work item to update."),
254
+ steps: z
255
+ .string()
256
+ .describe("The steps to reproduce the test case. Make sure to format each step as '1. Step one|Expected result one\n2. Step two|Expected result two. USE '|' as the delimiter between step and expected result. DO NOT use '|' in the description of the step or expected result."),
257
+ }, async ({ id, steps }) => {
258
+ try {
259
+ const connection = await connectionProvider();
260
+ const witClient = await connection.getWorkItemTrackingApi();
261
+ let stepsXml;
262
+ if (steps) {
263
+ stepsXml = convertStepsToXml(steps);
264
+ }
265
+ // Create JSON patch document for work item
266
+ const patchDocument = [];
267
+ if (stepsXml) {
268
+ patchDocument.push({
269
+ op: "add",
270
+ path: "/fields/Microsoft.VSTS.TCM.Steps",
271
+ value: stepsXml,
272
+ });
273
+ }
274
+ const workItem = await witClient.updateWorkItem({}, patchDocument, id);
275
+ return {
276
+ content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }],
277
+ };
278
+ }
279
+ catch (error) {
280
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
281
+ return {
282
+ content: [{ type: "text", text: `Error updating test case steps: ${errorMessage}` }],
283
+ isError: true,
284
+ };
285
+ }
286
+ });
287
+ server.tool(Test_Plan_Tools.list_test_cases, "Gets a list of test cases in the test plan.", {
288
+ project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
289
+ planid: z.coerce.number().min(1).describe("The ID of the test plan."),
290
+ suiteid: z.coerce.number().min(1).describe("The ID of the test suite."),
291
+ continuationToken: z.string().optional().describe("Token to continue fetching test cases from a previous request."),
292
+ }, async ({ project, planid, suiteid, continuationToken }) => {
293
+ try {
294
+ const connection = await connectionProvider();
295
+ const accessToken = await tokenProvider();
296
+ const params = new URLSearchParams({ "api-version": "7.2-preview.3" });
297
+ if (continuationToken)
298
+ params.append("continuationToken", continuationToken);
299
+ const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans/${planid}/Suites/${suiteid}/TestCase?${params.toString()}`;
300
+ const headers = {
301
+ Authorization: `Bearer ${accessToken}`,
302
+ };
303
+ const userAgent = userAgentProvider?.();
304
+ if (userAgent) {
305
+ headers["User-Agent"] = userAgent;
306
+ }
307
+ const response = await fetch(url, {
308
+ method: "GET",
309
+ headers,
310
+ });
311
+ if (!response.ok) {
312
+ const errorText = await response.text();
313
+ throw new Error(`Failed to list test cases (${response.status}): ${errorText}`);
314
+ }
315
+ const body = await response.json();
316
+ const testcases = body.value ?? [];
317
+ const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
318
+ const result = {
319
+ testCases: testcases,
320
+ };
321
+ if (nextToken) {
322
+ result.continuationToken = nextToken;
323
+ }
324
+ return {
325
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
326
+ };
327
+ }
328
+ catch (error) {
329
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
330
+ return {
331
+ content: [{ type: "text", text: `Error listing test cases: ${errorMessage}` }],
332
+ isError: true,
333
+ };
334
+ }
335
+ });
336
+ server.tool(Test_Plan_Tools.test_results_from_build_id, "Gets a list of test results for a given project and build ID. Can filter by test outcome (e.g. Failed, Passed, Aborted). Returns test case titles, error messages, stack traces, and outcomes. Efficiently handles builds with large numbers of test runs.", {
337
+ project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
338
+ buildid: z.coerce.number().min(1).describe("The ID of the build."),
339
+ outcomes: z.array(z.string()).optional().describe("Filter results by test outcome, e.g. ['Failed', 'Passed', 'Aborted']."),
340
+ }, async ({ project, buildid, outcomes }) => {
341
+ try {
342
+ const connection = await connectionProvider();
343
+ const testResultsApi = await connection.getTestResultsApi();
344
+ // Build filter expression for outcomes if specified.
345
+ // The API accepts: Outcome eq Failed,Passed (unquoted, comma-separated)
346
+ const outcomeFilter = outcomes?.length ? `Outcome eq ${outcomes.join(",")}` : undefined;
347
+ // Fetch test result details for the build in a single API call
348
+ // This is more efficient than getTestRuns + getTestResults per run,
349
+ // especially for builds with many test runs (e.g., cloud testing with one run per test case)
350
+ const testResultDetails = await testResultsApi.getTestResultDetailsForBuild(project, buildid, undefined, // publishContext
351
+ undefined, // groupBy
352
+ outcomeFilter, // filter by outcome
353
+ undefined, // orderby
354
+ true // shouldIncludeResults - get individual test results, not just aggregates
355
+ );
356
+ // Extract individual test results from the grouped response
357
+ const allResults = [];
358
+ if (testResultDetails.resultsForGroup) {
359
+ for (const group of testResultDetails.resultsForGroup) {
360
+ if (group.results) {
361
+ for (const result of group.results) {
362
+ allResults.push(result);
363
+ }
364
+ }
365
+ }
366
+ }
367
+ // Format results to extract useful fields
368
+ const formattedResults = allResults.map((r) => ({
369
+ id: r.id,
370
+ testCaseTitle: r.testCaseTitle,
371
+ outcome: r.outcome,
372
+ errorMessage: r.errorMessage,
373
+ stackTrace: r.stackTrace,
374
+ automatedTestName: r.automatedTestName,
375
+ automatedTestStorage: r.automatedTestStorage,
376
+ durationInMs: r.durationInMs,
377
+ runId: r.testRun?.id,
378
+ }));
379
+ return {
380
+ content: [{ type: "text", text: JSON.stringify(formattedResults, null, 2) }],
381
+ };
382
+ }
383
+ catch (error) {
384
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
385
+ return {
386
+ content: [{ type: "text", text: `Error fetching test results: ${errorMessage}` }],
387
+ isError: true,
388
+ };
389
+ }
390
+ });
391
+ server.tool(Test_Plan_Tools.list_test_suites, "Retrieve a paginated list of test suites from an Azure DevOps project and Test Plan Id.", {
392
+ project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
393
+ planId: z.coerce.number().min(1).describe("The ID of the test plan."),
394
+ continuationToken: z.string().optional().describe("Token to continue fetching test plans from a previous request."),
395
+ }, async ({ project, planId, continuationToken }) => {
396
+ try {
397
+ const connection = await connectionProvider();
398
+ const accessToken = await tokenProvider();
399
+ const params = new URLSearchParams({ "api-version": apiVersion, "expand": "children" });
400
+ if (continuationToken)
401
+ params.append("continuationToken", continuationToken);
402
+ const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans/${planId}/Suites?${params.toString()}`;
403
+ const headers = {
404
+ Authorization: `Bearer ${accessToken}`,
405
+ };
406
+ const userAgent = userAgentProvider?.();
407
+ if (userAgent) {
408
+ headers["User-Agent"] = userAgent;
409
+ }
410
+ const response = await fetch(url, {
411
+ method: "GET",
412
+ headers,
413
+ });
414
+ if (!response.ok) {
415
+ const errorText = await response.text();
416
+ throw new Error(`Failed to list test suites (${response.status}): ${errorText}`);
417
+ }
418
+ const body = await response.json();
419
+ const testSuites = body.value ?? [];
420
+ const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
421
+ // The API returns a flat list where the root suite is first, followed by all nested suites
422
+ // We need to build a proper hierarchy by creating a map and assembling the tree
423
+ // Create a map of all suites by ID for quick lookup
424
+ const suiteMap = new Map();
425
+ testSuites.forEach((suite) => {
426
+ suiteMap.set(suite.id, {
427
+ id: suite.id,
428
+ name: suite.name,
429
+ parentSuiteId: suite.parentSuite?.id,
430
+ children: [],
431
+ });
432
+ });
433
+ // Build the hierarchy by linking children to parents
434
+ const roots = [];
435
+ suiteMap.forEach((suite) => {
436
+ if (suite.parentSuiteId && suiteMap.has(suite.parentSuiteId)) {
437
+ // This is a child suite, add it to its parent's children array
438
+ const parent = suiteMap.get(suite.parentSuiteId);
439
+ parent.children.push(suite);
440
+ }
441
+ else {
442
+ // This is a root suite (no parent or parent not in map)
443
+ roots.push(suite);
444
+ }
445
+ });
446
+ // Clean up the output - remove parentSuiteId and empty children arrays
447
+ const cleanSuite = (suite) => {
448
+ const cleaned = {
449
+ id: suite.id,
450
+ name: suite.name,
451
+ };
452
+ if (suite.children && suite.children.length > 0) {
453
+ cleaned.children = suite.children.map((child) => cleanSuite(child));
454
+ }
455
+ return cleaned;
456
+ };
457
+ const cleanedSuites = roots.map((root) => cleanSuite(root));
458
+ const result = {
459
+ testSuites: cleanedSuites,
460
+ };
461
+ if (nextToken) {
462
+ result.continuationToken = nextToken;
463
+ }
464
+ return {
465
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
466
+ };
467
+ }
468
+ catch (error) {
469
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
470
+ return {
471
+ content: [{ type: "text", text: `Error listing test suites: ${errorMessage}` }],
472
+ isError: true,
473
+ };
474
+ }
475
+ });
476
+ }
477
+ /*
478
+ * Helper function to convert steps text to XML format required
479
+ */
480
+ function convertStepsToXml(steps) {
481
+ // Accepts steps in the format: '1. Step one|Expected result one\n2. Step two|Expected result two'
482
+ const stepsLines = steps.split("\n").filter((line) => line.trim() !== "");
483
+ let xmlSteps = `<steps id="0" last="${stepsLines.length}">`;
484
+ for (let i = 0; i < stepsLines.length; i++) {
485
+ const stepLine = stepsLines[i].trim();
486
+ if (stepLine) {
487
+ // Split step and expected result by '|', fallback to default if not provided
488
+ const [stepPart, expectedPart] = stepLine.split("|").map((s) => s.trim());
489
+ const stepMatch = stepPart.match(/^(\d+)\.\s*(.+)$/);
490
+ const stepText = stepMatch ? stepMatch[2] : stepPart;
491
+ const expectedText = expectedPart || "Verify step completes successfully";
492
+ xmlSteps += `
493
+ <step id="${i + 1}" type="ActionStep">
494
+ <parameterizedString isformatted="true">${escapeXml(stepText)}</parameterizedString>
495
+ <parameterizedString isformatted="true">${escapeXml(expectedText)}</parameterizedString>
496
+ </step>`;
497
+ }
498
+ }
499
+ xmlSteps += "</steps>";
500
+ return xmlSteps;
501
+ }
502
+ /*
503
+ * Helper function to escape XML special characters
504
+ */
505
+ function escapeXml(unsafe) {
506
+ return unsafe.replace(/[<>&'"]/g, (c) => {
507
+ switch (c) {
508
+ case "<":
509
+ return "&lt;";
510
+ case ">":
511
+ return "&gt;";
512
+ case "&":
513
+ return "&amp;";
514
+ case "'":
515
+ return "&apos;";
516
+ case '"':
517
+ return "&quot;";
518
+ default:
519
+ return c;
520
+ }
521
+ });
522
+ }
523
+ export { Test_Plan_Tools, configureTestPlanTools };