Skip to content

Commit 30b3103

Browse files
authored
Add tool to list test suites in Azure DevOps project (#744)
Adding new tool to list test suites and child suites. This will allow you to get a list of all the suites so you can get all test cases for an entire test plan. I did need to clean up the responses to show a subset of the results from the REST API. The API responses included a lot of noise not needed for this tool. ## GitHub issue number #722 ## **Associated Risks** None ## ✅ **PR Checklist** - [x] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [x] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [x] Title of the pull request is clear and informative. - [x] 👌 Code hygiene - [x] 🔭 Telemetry added, updated, or N/A - [x] 📄 Documentation added, updated, or N/A - [x] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** Added automated tests and tested it manually
1 parent d27259b commit 30b3103

File tree

3 files changed

+344
-1
lines changed

3 files changed

+344
-1
lines changed

docs/TOOLSET.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
| Search | [mcp_ado_search_workitem](#mcp_ado_search_workitem) | Search work items by text and filters |
4545
| Test Plans | [mcp_ado_testplan_list_test_plans](#mcp_ado_testplan_list_test_plans) | List test plans in a project |
4646
| Test Plans | [mcp_ado_testplan_create_test_plan](#mcp_ado_testplan_create_test_plan) | Create a new test plan |
47+
| Test Plans | [mcp_ado_testplan_list_test_suites](#mcp_ado_testplan_list_test_suites) | List test suites in a test plan |
4748
| Test Plans | [mcp_ado_testplan_create_test_suite](#mcp_ado_testplan_create_test_suite) | Create a test suite within a test plan |
4849
| Test Plans | [mcp_ado_testplan_add_test_cases_to_suite](#mcp_ado_testplan_add_test_cases_to_suite) | Add test cases to a test suite |
4950
| Test Plans | [mcp_ado_testplan_list_test_cases](#mcp_ado_testplan_list_test_cases) | List test cases in a test suite |
@@ -376,6 +377,13 @@ Creates a new test plan in the project.
376377
- **Required**: `project`, `name`, `iteration`
377378
- **Optional**: `areaPath`, `description`, `endDate`, `startDate`
378379

380+
### mcp_ado_testplan_list_test_suites
381+
382+
Retrieve a paginated list of test suites from an Azure DevOps project and Test Plan Id. Returns test suites in a properly nested hierarchical structure.
383+
384+
- **Required**: `project`, `planId`
385+
- **Optional**: `continuationToken`
386+
379387
### mcp_ado_testplan_create_test_suite
380388

381389
Creates a new test suite in a test plan.

src/tools/test-plans.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
55
import { WebApi } from "azure-devops-node-api";
6-
import { TestPlanCreateParams } from "azure-devops-node-api/interfaces/TestPlanInterfaces.js";
6+
import { SuiteExpand, TestPlanCreateParams } from "azure-devops-node-api/interfaces/TestPlanInterfaces.js";
77
import { z } from "zod";
88

99
const Test_Plan_Tools = {
@@ -14,6 +14,7 @@ const Test_Plan_Tools = {
1414
test_results_from_build_id: "testplan_show_test_results_from_build_id",
1515
list_test_cases: "testplan_list_test_cases",
1616
list_test_plans: "testplan_list_test_plans",
17+
list_test_suites: "testplan_list_test_suites",
1718
create_test_suite: "testplan_create_test_suite",
1819
};
1920

@@ -352,6 +353,76 @@ function configureTestPlanTools(server: McpServer, _: () => Promise<string>, con
352353
}
353354
}
354355
);
356+
357+
server.tool(
358+
Test_Plan_Tools.list_test_suites,
359+
"Retrieve a paginated list of test suites from an Azure DevOps project and Test Plan Id.",
360+
{
361+
project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
362+
planId: z.number().describe("The ID of the test plan."),
363+
continuationToken: z.string().optional().describe("Token to continue fetching test plans from a previous request."),
364+
},
365+
async ({ project, planId, continuationToken }) => {
366+
try {
367+
const connection = await connectionProvider();
368+
const testPlanApi = await connection.getTestPlanApi();
369+
const expand: SuiteExpand = SuiteExpand.Children;
370+
371+
const testSuites = await testPlanApi.getTestSuitesForPlan(project, planId, expand, continuationToken);
372+
373+
// The API returns a flat list where the root suite is first, followed by all nested suites
374+
// We need to build a proper hierarchy by creating a map and assembling the tree
375+
376+
// Create a map of all suites by ID for quick lookup
377+
const suiteMap = new Map();
378+
testSuites.forEach((suite: any) => {
379+
suiteMap.set(suite.id, {
380+
id: suite.id,
381+
name: suite.name,
382+
parentSuiteId: suite.parentSuite?.id,
383+
children: [] as any[],
384+
});
385+
});
386+
387+
// Build the hierarchy by linking children to parents
388+
const roots: any[] = [];
389+
suiteMap.forEach((suite: any) => {
390+
if (suite.parentSuiteId && suiteMap.has(suite.parentSuiteId)) {
391+
// This is a child suite, add it to its parent's children array
392+
const parent = suiteMap.get(suite.parentSuiteId);
393+
parent.children.push(suite);
394+
} else {
395+
// This is a root suite (no parent or parent not in map)
396+
roots.push(suite);
397+
}
398+
});
399+
400+
// Clean up the output - remove parentSuiteId and empty children arrays
401+
const cleanSuite = (suite: any): any => {
402+
const cleaned: any = {
403+
id: suite.id,
404+
name: suite.name,
405+
};
406+
if (suite.children && suite.children.length > 0) {
407+
cleaned.children = suite.children.map((child: any) => cleanSuite(child));
408+
}
409+
return cleaned;
410+
};
411+
412+
const result = roots.map((root: any) => cleanSuite(root));
413+
414+
return {
415+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
416+
};
417+
} catch (error) {
418+
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
419+
return {
420+
content: [{ type: "text", text: `Error listing test suites: ${errorMessage}` }],
421+
isError: true,
422+
};
423+
}
424+
}
425+
);
355426
}
356427

357428
/*

test/src/tools/test-plan.test.ts

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ describe("configureTestPlanTools", () => {
7272
"testplan_update_test_case_steps",
7373
"testplan_list_test_cases",
7474
"testplan_show_test_results_from_build_id",
75+
"testplan_list_test_suites",
7576
])
7677
);
7778
});
@@ -118,6 +119,269 @@ describe("configureTestPlanTools", () => {
118119
});
119120
});
120121

122+
describe("list_test_suites tool", () => {
123+
beforeEach(() => {
124+
(mockTestPlanApi as any).getTestSuitesForPlan = jest.fn();
125+
});
126+
127+
it("should call getTestSuitesForPlan and return properly nested hierarchy", async () => {
128+
configureTestPlanTools(server, tokenProvider, connectionProvider);
129+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites");
130+
if (!call) throw new Error("testplan_list_test_suites tool not registered");
131+
const [, , , handler] = call;
132+
133+
// Mock API response with flat list including nested suites
134+
((mockTestPlanApi as any).getTestSuitesForPlan as jest.Mock).mockResolvedValue([
135+
{
136+
id: 100,
137+
name: "Root Suite",
138+
hasChildren: true,
139+
children: [
140+
{ id: 101, name: "Child Suite 1", parentSuite: { id: 100 } },
141+
{ id: 102, name: "Child Suite 2", parentSuite: { id: 100 } },
142+
],
143+
},
144+
{
145+
id: 101,
146+
name: "Child Suite 1",
147+
hasChildren: true,
148+
parentSuite: { id: 100 },
149+
children: [{ id: 103, name: "Grandchild Suite", parentSuite: { id: 101 } }],
150+
},
151+
{
152+
id: 102,
153+
name: "Child Suite 2",
154+
parentSuite: { id: 100 },
155+
},
156+
{
157+
id: 103,
158+
name: "Grandchild Suite",
159+
parentSuite: { id: 101 },
160+
},
161+
]);
162+
163+
const params = {
164+
project: "proj1",
165+
planId: 1,
166+
};
167+
const result = await handler(params);
168+
169+
expect((mockTestPlanApi as any).getTestSuitesForPlan).toHaveBeenCalledWith("proj1", 1, 1, undefined);
170+
171+
// Parse and validate the nested structure
172+
const parsed = JSON.parse(result.content[0].text);
173+
expect(parsed).toHaveLength(1);
174+
expect(parsed[0]).toMatchObject({
175+
id: 100,
176+
name: "Root Suite",
177+
children: [
178+
{
179+
id: 101,
180+
name: "Child Suite 1",
181+
children: [
182+
{
183+
id: 103,
184+
name: "Grandchild Suite",
185+
},
186+
],
187+
},
188+
{
189+
id: 102,
190+
name: "Child Suite 2",
191+
},
192+
],
193+
});
194+
});
195+
196+
it("should handle test suite with no children", async () => {
197+
configureTestPlanTools(server, tokenProvider, connectionProvider);
198+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites");
199+
if (!call) throw new Error("testplan_list_test_suites tool not registered");
200+
const [, , , handler] = call;
201+
202+
((mockTestPlanApi as any).getTestSuitesForPlan as jest.Mock).mockResolvedValue([
203+
{
204+
id: 200,
205+
name: "Single Suite",
206+
hasChildren: false,
207+
},
208+
]);
209+
210+
const params = {
211+
project: "proj1",
212+
planId: 2,
213+
};
214+
const result = await handler(params);
215+
216+
const parsed = JSON.parse(result.content[0].text);
217+
expect(parsed).toHaveLength(1);
218+
expect(parsed[0]).toEqual({
219+
id: 200,
220+
name: "Single Suite",
221+
});
222+
});
223+
224+
it("should handle empty test suite list", async () => {
225+
configureTestPlanTools(server, tokenProvider, connectionProvider);
226+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites");
227+
if (!call) throw new Error("testplan_list_test_suites tool not registered");
228+
const [, , , handler] = call;
229+
230+
((mockTestPlanApi as any).getTestSuitesForPlan as jest.Mock).mockResolvedValue([]);
231+
232+
const params = {
233+
project: "proj1",
234+
planId: 3,
235+
};
236+
const result = await handler(params);
237+
238+
const parsed = JSON.parse(result.content[0].text);
239+
expect(parsed).toEqual([]);
240+
});
241+
242+
it("should handle deeply nested suite hierarchy", async () => {
243+
configureTestPlanTools(server, tokenProvider, connectionProvider);
244+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites");
245+
if (!call) throw new Error("testplan_list_test_suites tool not registered");
246+
const [, , , handler] = call;
247+
248+
// Mock a deeply nested structure
249+
((mockTestPlanApi as any).getTestSuitesForPlan as jest.Mock).mockResolvedValue([
250+
{
251+
id: 300,
252+
name: "Root",
253+
hasChildren: true,
254+
children: [{ id: 301, name: "Level 1", parentSuite: { id: 300 } }],
255+
},
256+
{
257+
id: 301,
258+
name: "Level 1",
259+
hasChildren: true,
260+
parentSuite: { id: 300 },
261+
children: [{ id: 302, name: "Level 2", parentSuite: { id: 301 } }],
262+
},
263+
{
264+
id: 302,
265+
name: "Level 2",
266+
hasChildren: true,
267+
parentSuite: { id: 301 },
268+
children: [{ id: 303, name: "Level 3", parentSuite: { id: 302 } }],
269+
},
270+
{
271+
id: 303,
272+
name: "Level 3",
273+
parentSuite: { id: 302 },
274+
},
275+
]);
276+
277+
const params = {
278+
project: "proj1",
279+
planId: 4,
280+
};
281+
const result = await handler(params);
282+
283+
const parsed = JSON.parse(result.content[0].text);
284+
expect(parsed[0]).toMatchObject({
285+
id: 300,
286+
name: "Root",
287+
children: [
288+
{
289+
id: 301,
290+
name: "Level 1",
291+
children: [
292+
{
293+
id: 302,
294+
name: "Level 2",
295+
children: [
296+
{
297+
id: 303,
298+
name: "Level 3",
299+
},
300+
],
301+
},
302+
],
303+
},
304+
],
305+
});
306+
});
307+
308+
it("should handle API errors when listing test suites", async () => {
309+
configureTestPlanTools(server, tokenProvider, connectionProvider);
310+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites");
311+
if (!call) throw new Error("testplan_list_test_suites tool not registered");
312+
const [, , , handler] = call;
313+
314+
((mockTestPlanApi as any).getTestSuitesForPlan as jest.Mock).mockRejectedValue(new Error("API Error"));
315+
316+
const params = {
317+
project: "proj1",
318+
planId: 5,
319+
};
320+
const result = await handler(params);
321+
322+
expect(result.isError).toBe(true);
323+
expect(result.content[0].text).toContain("Error listing test suites: API Error");
324+
});
325+
326+
it("should pass continuation token when provided", async () => {
327+
configureTestPlanTools(server, tokenProvider, connectionProvider);
328+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites");
329+
if (!call) throw new Error("testplan_list_test_suites tool not registered");
330+
const [, , , handler] = call;
331+
332+
((mockTestPlanApi as any).getTestSuitesForPlan as jest.Mock).mockResolvedValue([
333+
{
334+
id: 400,
335+
name: "Suite with Token",
336+
},
337+
]);
338+
339+
const params = {
340+
project: "proj1",
341+
planId: 6,
342+
continuationToken: "token123",
343+
};
344+
await handler(params);
345+
346+
expect((mockTestPlanApi as any).getTestSuitesForPlan).toHaveBeenCalledWith("proj1", 6, 1, "token123");
347+
});
348+
349+
it("should not include empty children arrays in output", async () => {
350+
configureTestPlanTools(server, tokenProvider, connectionProvider);
351+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites");
352+
if (!call) throw new Error("testplan_list_test_suites tool not registered");
353+
const [, , , handler] = call;
354+
355+
((mockTestPlanApi as any).getTestSuitesForPlan as jest.Mock).mockResolvedValue([
356+
{
357+
id: 500,
358+
name: "Parent",
359+
hasChildren: true,
360+
children: [{ id: 501, name: "Child with no children", parentSuite: { id: 500 } }],
361+
},
362+
{
363+
id: 501,
364+
name: "Child with no children",
365+
parentSuite: { id: 500 },
366+
hasChildren: false,
367+
},
368+
]);
369+
370+
const params = {
371+
project: "proj1",
372+
planId: 7,
373+
};
374+
const result = await handler(params);
375+
376+
const parsed = JSON.parse(result.content[0].text);
377+
expect(parsed[0].children[0]).toEqual({
378+
id: 501,
379+
name: "Child with no children",
380+
});
381+
expect(parsed[0].children[0].children).toBeUndefined();
382+
});
383+
});
384+
121385
describe("create_test_plan tool", () => {
122386
it("should call createTestPlan with the correct parameters and return the expected result", async () => {
123387
configureTestPlanTools(server, tokenProvider, connectionProvider);

0 commit comments

Comments
 (0)