@azure-devops/mcp 2.8.1 → 2.9.0

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.
@@ -2,35 +2,29 @@
2
2
  // Licensed under the MIT License.
3
3
  import { z } from "zod";
4
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",
5
+ const TEST_PLAN_TOOLS = {
6
+ testplan: "testplan",
10
7
  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",
8
+ testplan_test_plan_write: "testplan_test_plan_write",
9
+ testplan_test_suite_write: "testplan_test_suite_write",
10
+ testplan_test_case_write: "testplan_test_case_write",
15
11
  };
16
12
  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.", {
13
+ // ─── testplan (read-only) ────────────────────────────────────────────
14
+ server.tool(TEST_PLAN_TOOLS.testplan, "Retrieve paginated test plan, suite, and case data for a project. Use the action parameter to specify the operation. When a response includes a continuationToken, pass it back with the same action and query parameters to fetch the next batch; null token indicates the last batch.", {
15
+ action: z
16
+ .enum(["list_plans", "list_suites", "list_cases"])
17
+ .describe("The action to perform. Options: list_plans (list test plans in a project), list_suites (list test suites under a test plan), list_cases (list test cases under a test suite)."),
18
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 }) => {
19
+ filterActivePlans: z.boolean().default(true).describe("Filter to include only active test plans. Used for: list_plans. Defaults to true."),
20
+ includePlanDetails: z.boolean().default(false).describe("Include detailed information about each test plan. Used for: list_plans."),
21
+ planId: z.coerce.number().min(1).optional().describe("The ID of the test plan. Required for: list_suites, list_cases."),
22
+ suiteId: z.coerce.number().min(1).optional().describe("The ID of the test suite. Required for: list_cases."),
23
+ continuationToken: z.string().optional().describe("Token to continue fetching results from a previous request. Used for: list_plans, list_suites, list_cases."),
24
+ }, async ({ action, project, filterActivePlans, includePlanDetails, planId, suiteId, continuationToken }) => {
23
25
  try {
24
26
  const connection = await connectionProvider();
25
27
  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
28
  const headers = {
35
29
  Authorization: `Bearer ${accessToken}`,
36
30
  };
@@ -38,302 +32,109 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider, userA
38
32
  if (userAgent) {
39
33
  headers["User-Agent"] = userAgent;
40
34
  }
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
35
+ if (action === "list_plans") {
36
+ const params = new URLSearchParams({ "api-version": apiVersion });
37
+ if (filterActivePlans)
38
+ params.append("filterActivePlans", "true");
39
+ if (includePlanDetails)
40
+ params.append("includePlanDetails", "true");
41
+ if (continuationToken)
42
+ params.append("continuationToken", continuationToken);
43
+ const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans?${params.toString()}`;
44
+ const response = await fetch(url, { method: "GET", headers });
45
+ if (!response.ok) {
46
+ const errorText = await response.text();
47
+ throw new Error(`Failed to list test plans (${response.status}): ${errorText}`);
137
48
  }
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
- };
49
+ const body = await response.json();
50
+ const testPlans = body.value ?? [];
51
+ const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
52
+ const result = { testPlans };
53
+ if (nextToken)
54
+ result.continuationToken = nextToken;
55
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
143
56
  }
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,
57
+ else if (action === "list_suites") {
58
+ if (!planId)
59
+ return { content: [{ type: "text", text: "planId is required for list_suites" }], isError: true };
60
+ const params = new URLSearchParams({ "api-version": apiVersion, "expand": "children" });
61
+ if (continuationToken)
62
+ params.append("continuationToken", continuationToken);
63
+ const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans/${planId}/Suites?${params.toString()}`;
64
+ const response = await fetch(url, { method: "GET", headers });
65
+ if (!response.ok) {
66
+ const errorText = await response.text();
67
+ throw new Error(`Failed to list test suites (${response.status}): ${errorText}`);
68
+ }
69
+ const body = await response.json();
70
+ const testSuites = body.value ?? [];
71
+ const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
72
+ const suiteMap = new Map();
73
+ testSuites.forEach((suite) => {
74
+ suiteMap.set(suite.id, {
75
+ id: suite.id,
76
+ name: suite.name,
77
+ parentSuiteId: suite.parentSuite?.id,
78
+ children: [],
79
+ });
237
80
  });
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,
81
+ const roots = [];
82
+ suiteMap.forEach((suite) => {
83
+ if (suite.parentSuiteId && suiteMap.has(suite.parentSuiteId)) {
84
+ suiteMap.get(suite.parentSuiteId).children.push(suite);
85
+ }
86
+ else {
87
+ roots.push(suite);
88
+ }
272
89
  });
90
+ const cleanSuite = (suite) => {
91
+ const cleaned = { id: suite.id, name: suite.name };
92
+ if (suite.children && suite.children.length > 0) {
93
+ cleaned.children = suite.children.map((child) => cleanSuite(child));
94
+ }
95
+ return cleaned;
96
+ };
97
+ const cleanedSuites = roots.map((root) => cleanSuite(root));
98
+ const result = { testSuites: cleanedSuites };
99
+ if (nextToken)
100
+ result.continuationToken = nextToken;
101
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
273
102
  }
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;
103
+ else if (action === "list_cases") {
104
+ if (!planId)
105
+ return { content: [{ type: "text", text: "planId is required for list_cases" }], isError: true };
106
+ if (!suiteId)
107
+ return { content: [{ type: "text", text: "suiteId is required for list_cases" }], isError: true };
108
+ const params = new URLSearchParams({ "api-version": "7.2-preview.3" });
109
+ if (continuationToken)
110
+ params.append("continuationToken", continuationToken);
111
+ const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans/${planId}/Suites/${suiteId}/TestCase?${params.toString()}`;
112
+ const response = await fetch(url, { method: "GET", headers });
113
+ if (!response.ok) {
114
+ const errorText = await response.text();
115
+ throw new Error(`Failed to list test cases (${response.status}): ${errorText}`);
116
+ }
117
+ const body = await response.json();
118
+ const testcases = body.value ?? [];
119
+ const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
120
+ const result = { testCases: testcases };
121
+ if (nextToken)
122
+ result.continuationToken = nextToken;
123
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
323
124
  }
324
- return {
325
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
326
- };
125
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
327
126
  }
328
127
  catch (error) {
329
128
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
129
+ const prefix = action === "list_plans" ? "Error listing test plans" : action === "list_suites" ? "Error listing test suites" : "Error listing test cases";
330
130
  return {
331
- content: [{ type: "text", text: `Error listing test cases: ${errorMessage}` }],
131
+ content: [{ type: "text", text: `${prefix}: ${errorMessage}` }],
332
132
  isError: true,
333
133
  };
334
134
  }
335
135
  });
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.", {
136
+ // ─── testplan_show_test_results_from_build_id ──────────────────────────────────────
137
+ 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
138
  project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
338
139
  buildid: z.coerce.number().min(1).describe("The ID of the build."),
339
140
  outcomes: z.array(z.string()).optional().describe("Filter results by test outcome, e.g. ['Failed', 'Passed', 'Aborted']."),
@@ -341,19 +142,8 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider, userA
341
142
  try {
342
143
  const connection = await connectionProvider();
343
144
  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
145
  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
146
+ const testResultDetails = await testResultsApi.getTestResultDetailsForBuild(project, buildid, undefined, undefined, outcomeFilter, undefined, true);
357
147
  const allResults = [];
358
148
  if (testResultDetails.resultsForGroup) {
359
149
  for (const group of testResultDetails.resultsForGroup) {
@@ -364,7 +154,6 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider, userA
364
154
  }
365
155
  }
366
156
  }
367
- // Format results to extract useful fields
368
157
  const formattedResults = allResults.map((r) => ({
369
158
  id: r.id,
370
159
  testCaseTitle: r.testCaseTitle,
@@ -388,87 +177,204 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider, userA
388
177
  };
389
178
  }
390
179
  });
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.", {
180
+ // ─── testplan_test_plan_write ─────────────────────────────────────────────────────
181
+ server.tool(TEST_PLAN_TOOLS.testplan_test_plan_write, "Write operations for test plans. Use the action parameter to specify the operation.", {
182
+ action: z.enum(["create"]).describe("The action to perform. Options: create (create a new test plan)."),
392
183
  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 }) => {
184
+ name: z.string().optional().describe("The name of the test plan. Required for: create."),
185
+ iteration: z.string().optional().describe("The iteration path for the test plan. Required for: create."),
186
+ description: z.string().optional().describe("The description of the test plan. Used for: create."),
187
+ startDate: z.string().optional().describe("The start date of the test plan. Used for: create."),
188
+ endDate: z.string().optional().describe("The end date of the test plan. Used for: create."),
189
+ areaPath: z.string().optional().describe("The area path for the test plan. Used for: create."),
190
+ }, async ({ project, name, iteration, description, startDate, endDate, areaPath }) => {
396
191
  try {
192
+ if (!name)
193
+ return { content: [{ type: "text", text: "name is required for create" }], isError: true };
194
+ if (!iteration)
195
+ return { content: [{ type: "text", text: "iteration is required for create" }], isError: true };
397
196
  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}`,
197
+ const testPlanApi = await connection.getTestPlanApi();
198
+ const testPlanToCreate = {
199
+ name,
200
+ iteration,
201
+ description,
202
+ startDate: startDate ? new Date(startDate) : undefined,
203
+ endDate: endDate ? new Date(endDate) : undefined,
204
+ areaPath,
405
205
  };
406
- const userAgent = userAgentProvider?.();
407
- if (userAgent) {
408
- headers["User-Agent"] = userAgent;
206
+ const createdTestPlan = await testPlanApi.createTestPlan(testPlanToCreate, project);
207
+ return {
208
+ content: [{ type: "text", text: JSON.stringify(createdTestPlan, null, 2) }],
209
+ };
210
+ }
211
+ catch (error) {
212
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
213
+ return {
214
+ content: [{ type: "text", text: `Error creating test plan: ${errorMessage}` }],
215
+ isError: true,
216
+ };
217
+ }
218
+ });
219
+ // ─── testplan_test_suite_write ────────────────────────────────────────────────────
220
+ server.tool(TEST_PLAN_TOOLS.testplan_test_suite_write, "Write operations for test suites. Use the action parameter to specify the operation.", {
221
+ action: z
222
+ .enum(["create", "add_test_cases"])
223
+ .describe("The action to perform. Options: create (create a new test suite in a test plan), add_test_cases (add existing test cases to a test suite)."),
224
+ project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
225
+ planId: z.coerce.number().min(1).optional().describe("The ID of the test plan. Required for: create, add_test_cases."),
226
+ parentSuiteId: z.coerce.number().min(1).optional().describe("ID of the parent suite under which the new suite will be created. Required for: create."),
227
+ name: z.string().optional().describe("Name of the child test suite. Required for: create."),
228
+ suiteId: z.coerce.number().min(1).optional().describe("The ID of the test suite. Required for: add_test_cases."),
229
+ testCaseIds: z.string().or(z.array(z.string())).optional().describe("The ID(s) of the test case(s) to add. Required for: add_test_cases."),
230
+ }, async ({ action, project, planId, parentSuiteId, name, suiteId, testCaseIds }) => {
231
+ try {
232
+ if (action === "create") {
233
+ if (!planId)
234
+ return { content: [{ type: "text", text: "planId is required for create" }], isError: true };
235
+ if (!parentSuiteId)
236
+ return { content: [{ type: "text", text: "parentSuiteId is required for create" }], isError: true };
237
+ if (!name)
238
+ return { content: [{ type: "text", text: "name is required for create" }], isError: true };
239
+ const maxRetries = 5;
240
+ const baseDelay = 500;
241
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
242
+ try {
243
+ const connection = await connectionProvider();
244
+ const testPlanApi = await connection.getTestPlanApi();
245
+ const testSuiteToCreate = {
246
+ name,
247
+ parentSuite: { id: parentSuiteId, name: "" },
248
+ suiteType: 2,
249
+ };
250
+ const createdTestSuite = await testPlanApi.createTestSuite(testSuiteToCreate, project, planId);
251
+ return {
252
+ content: [{ type: "text", text: JSON.stringify(createdTestSuite, null, 2) }],
253
+ };
254
+ }
255
+ catch (error) {
256
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
257
+ const isConcurrencyError = errorMessage.includes("TF26071") || errorMessage.includes("got update") || errorMessage.includes("changed by someone else");
258
+ if (isConcurrencyError && attempt < maxRetries) {
259
+ const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 200;
260
+ await new Promise((resolve) => setTimeout(resolve, delay));
261
+ continue;
262
+ }
263
+ return {
264
+ content: [{ type: "text", text: `Error creating test suite: ${errorMessage}` }],
265
+ isError: true,
266
+ };
267
+ }
268
+ }
269
+ /* istanbul ignore next */
270
+ return {
271
+ content: [{ type: "text", text: "Error creating test suite: Maximum retries exceeded" }],
272
+ isError: true,
273
+ };
409
274
  }
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}`);
275
+ else if (action === "add_test_cases") {
276
+ if (!planId)
277
+ return { content: [{ type: "text", text: "planId is required for add_test_cases" }], isError: true };
278
+ if (!suiteId)
279
+ return { content: [{ type: "text", text: "suiteId is required for add_test_cases" }], isError: true };
280
+ if (!testCaseIds)
281
+ return { content: [{ type: "text", text: "testCaseIds is required for add_test_cases" }], isError: true };
282
+ const connection = await connectionProvider();
283
+ const testApi = await connection.getTestApi();
284
+ const testCaseIdsString = Array.isArray(testCaseIds) ? testCaseIds.join(",") : testCaseIds;
285
+ const addedTestCases = await testApi.addTestCasesToSuite(project, planId, suiteId, testCaseIdsString);
286
+ return {
287
+ content: [{ type: "text", text: JSON.stringify(addedTestCases, null, 2) }],
288
+ };
417
289
  }
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);
290
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
291
+ }
292
+ catch (error) {
293
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
294
+ return {
295
+ content: [{ type: "text", text: `Error adding test cases to suite: ${errorMessage}` }],
296
+ isError: true,
297
+ };
298
+ }
299
+ });
300
+ // ─── testplan_test_case_write ─────────────────────────────────────────────────────
301
+ server.tool(TEST_PLAN_TOOLS.testplan_test_case_write, "Write operations for test cases. Use the action parameter to specify the operation.", {
302
+ action: z.enum(["create", "update_steps"]).describe("The action to perform. Options: create (create a new test case work item), update_steps (update steps on an existing test case)."),
303
+ project: z.string().optional().describe("The unique identifier (ID or name) of the Azure DevOps project. Required for: create."),
304
+ title: z.string().optional().describe("The title of the test case. Required for: create."),
305
+ priority: z.coerce.number().optional().describe("The priority of the test case. Used for: create."),
306
+ areaPath: z.string().optional().describe("The area path for the test case. Used for: create."),
307
+ iterationPath: z.string().optional().describe("The iteration path for the test case. Used for: create."),
308
+ testsWorkItemId: z.coerce.number().min(1).optional().describe("Work item ID to set as a Microsoft.VSTS.Common.TestedBy-Reverse link. Used for: create."),
309
+ id: z.coerce.number().min(1).optional().describe("The ID of the test case work item to update. Required for: update_steps."),
310
+ steps: z
311
+ .string()
312
+ .optional()
313
+ .describe("The steps for the test case. 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. Required for: update_steps. Used for: create."),
314
+ }, async ({ action, project, title, steps, priority, areaPath, iterationPath, testsWorkItemId, id }) => {
315
+ try {
316
+ if (action === "create") {
317
+ if (!project)
318
+ return { content: [{ type: "text", text: "project is required for create" }], isError: true };
319
+ if (!title)
320
+ return { content: [{ type: "text", text: "title is required for create" }], isError: true };
321
+ const connection = await connectionProvider();
322
+ const witClient = await connection.getWorkItemTrackingApi();
323
+ let stepsXml;
324
+ if (steps) {
325
+ stepsXml = convertStepsToXml(steps);
440
326
  }
441
- else {
442
- // This is a root suite (no parent or parent not in map)
443
- roots.push(suite);
327
+ const patchDocument = [];
328
+ patchDocument.push({ op: "add", path: "/fields/System.Title", value: title });
329
+ if (testsWorkItemId) {
330
+ patchDocument.push({
331
+ op: "add",
332
+ path: "/relations/-",
333
+ value: {
334
+ rel: "Microsoft.VSTS.Common.TestedBy-Reverse",
335
+ url: `${connection.serverUrl}/${project}/_apis/wit/workItems/${testsWorkItemId}`,
336
+ },
337
+ });
444
338
  }
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));
339
+ if (stepsXml) {
340
+ patchDocument.push({ op: "add", path: "/fields/Microsoft.VSTS.TCM.Steps", value: stepsXml });
454
341
  }
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;
342
+ if (priority) {
343
+ patchDocument.push({ op: "add", path: "/fields/Microsoft.VSTS.Common.Priority", value: priority });
344
+ }
345
+ if (areaPath) {
346
+ patchDocument.push({ op: "add", path: "/fields/System.AreaPath", value: areaPath });
347
+ }
348
+ if (iterationPath) {
349
+ patchDocument.push({ op: "add", path: "/fields/System.IterationPath", value: iterationPath });
350
+ }
351
+ const workItem = await witClient.createWorkItem({}, patchDocument, project, "Test Case");
352
+ return {
353
+ content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }],
354
+ };
463
355
  }
464
- return {
465
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
466
- };
356
+ else if (action === "update_steps") {
357
+ if (!id)
358
+ return { content: [{ type: "text", text: "id is required for update_steps" }], isError: true };
359
+ if (!steps)
360
+ return { content: [{ type: "text", text: "steps is required for update_steps" }], isError: true };
361
+ const connection = await connectionProvider();
362
+ const witClient = await connection.getWorkItemTrackingApi();
363
+ const stepsXml = convertStepsToXml(steps);
364
+ const patchDocument = [];
365
+ patchDocument.push({ op: "add", path: "/fields/Microsoft.VSTS.TCM.Steps", value: stepsXml });
366
+ const workItem = await witClient.updateWorkItem({}, patchDocument, id);
367
+ return {
368
+ content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }],
369
+ };
370
+ }
371
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
467
372
  }
468
373
  catch (error) {
469
374
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
375
+ const prefix = action === "create" ? "Error creating test case" : "Error updating test case steps";
470
376
  return {
471
- content: [{ type: "text", text: `Error listing test suites: ${errorMessage}` }],
377
+ content: [{ type: "text", text: `${prefix}: ${errorMessage}` }],
472
378
  isError: true,
473
379
  };
474
380
  }
@@ -480,37 +386,31 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider, userA
480
386
  * element, which is the format Azure DevOps expects for rendered step content.
481
387
  */
482
388
  function formatStepContent(text) {
483
- // Convert Markdown markers to HTML tags (** before * and __ before _ to avoid conflicts)
484
389
  const htmlContent = text
485
390
  .replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
486
391
  .replace(/\*(.+?)\*/g, "<i>$1</i>")
487
392
  .replace(/__(.+?)__/g, "<u>$1</u>")
488
393
  .replace(/`(.+?)`/g, "<code>$1</code>")
489
394
  .replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2">$1</a>');
490
- // Wrap in ADO rich text envelope and XML-escape the entire HTML string
491
395
  return escapeXml(`${htmlContent}`);
492
396
  }
493
397
  /*
494
398
  * Helper function to convert steps text to XML format required
495
399
  */
496
400
  function convertStepsToXml(steps) {
497
- // Accepts steps in the format: '1. Step one|Expected result one\n2. Step two|Expected result two'
498
401
  const stepsLines = steps.split("\n").filter((line) => line.trim() !== "");
499
402
  let xmlSteps = `<steps id="0" last="${stepsLines.length}">`;
500
403
  for (let i = 0; i < stepsLines.length; i++) {
501
404
  const stepLine = stepsLines[i].trim();
502
- if (stepLine) {
503
- // Split step and expected result by '|', fallback to default if not provided
504
- const [stepPart, expectedPart] = stepLine.split("|").map((s) => s.trim());
505
- const stepMatch = stepPart.match(/^(\d+)\.\s*(.+)$/);
506
- const stepText = stepMatch ? stepMatch[2] : stepPart;
507
- const expectedText = expectedPart || "Verify step completes successfully";
508
- xmlSteps += `
405
+ const [stepPart, expectedPart] = stepLine.split("|").map((s) => s.trim());
406
+ const stepMatch = stepPart.match(/^(\d+)\.\s*(.+)$/);
407
+ const stepText = stepMatch ? stepMatch[2] : stepPart;
408
+ const expectedText = expectedPart || "Verify step completes successfully";
409
+ xmlSteps += `
509
410
  <step id="${i + 1}" type="ActionStep">
510
411
  <parameterizedString isformatted="true">${formatStepContent(stepText)}</parameterizedString>
511
412
  <parameterizedString isformatted="true">${formatStepContent(expectedText)}</parameterizedString>
512
413
  </step>`;
513
- }
514
414
  }
515
415
  xmlSteps += "</steps>";
516
416
  return xmlSteps;
@@ -531,9 +431,10 @@ function escapeXml(unsafe) {
531
431
  return "&apos;";
532
432
  case '"':
533
433
  return "&quot;";
434
+ /* istanbul ignore next */
534
435
  default:
535
436
  return c;
536
437
  }
537
438
  });
538
439
  }
539
- export { Test_Plan_Tools, configureTestPlanTools };
440
+ export { TEST_PLAN_TOOLS, configureTestPlanTools };