Repository: microsoft/azure-devops-mcp

Issue:
Add tool to list child test suites
Right now, I have claude connected to my azure devops with the MCP. I can pull test cases which is in a folder but cannot load test cases that are in subfolders of the that same folder.

Discussion:
@danhellem: @Genetec420 can you please be more specific on what it is you want to do? Maybe provide the available REST endpoints as well. Are you trying to get all test suites on a plan? or are you trying to pull all test cases in a plan regardless of what suite they are in?
@Genetec420: @danhellem Thanks for the quick response!
What I want to do:
Pull all test cases in a test plan (e.g., Plan ID 145918) regardless of which suite they're in, including test cases in nested sub-suites.
Current limitation:
The existing testplan_list_test_cases tool requires both planId and suiteId. This means:

I can only retrieve test cases from one suite at a time
Test cases in child/nested suites are not included
There's no MCP tool to list child suites, so I can't discover the suite hierarchy programmatically

Current workaround:
I have to manually identify all suite IDs from the ADO UI, then call testplan_list_test_cases multiple times for each suite. This doesn't scale well for large test plans with complex suite hierarchies.
Relevant Azure DevOps REST API endpoints:
Current approach uses:

GET https://dev.azure.com/{organization}/{project}/_apis/testplan/Plans/{planId}/Suites/{suiteId}/TestCase

What would help:

GET https://dev.azure.com/{organization}/{project}/_apis/test/Plans/{planId}/suites - to list all suites in a plan
A recursive parameter on the existing endpoint to include test cases from child suites

Ideal solution:
Either:

Add a new tool like testplan_list_all_test_cases that takes only planId and returns all test cases across all suites, OR
Add a tool like testplan_list_child_suites so I can discover and traverse the suite hierarchy programmatically

Let me know if you need any additional details!@danhellem: PR #744 is open and should be merged in the next couple of days. I did trim the responses from the endpoint to just include the suite name, id, children. The current API returns a lot of extra noise for this workflow.@Genetec420: Thanks guys!

Solution patch:
diff --git a/docs/TOOLSET.md b/docs/TOOLSET.md
index b3dad00..5c72fa3 100644
--- a/docs/TOOLSET.md
+++ b/docs/TOOLSET.md
@@ -44,6 +44,7 @@
 | Search            | [mcp_ado_search_workitem](#mcp_ado_search_workitem)                                                       | Search work items by text and filters                    |
 | Test Plans        | [mcp_ado_testplan_list_test_plans](#mcp_ado_testplan_list_test_plans)                                     | List test plans in a project                             |
 | Test Plans        | [mcp_ado_testplan_create_test_plan](#mcp_ado_testplan_create_test_plan)                                   | Create a new test plan                                   |
+| Test Plans        | [mcp_ado_testplan_list_test_suites](#mcp_ado_testplan_list_test_suites)                                   | List test suites in a test plan                          |
 | Test Plans        | [mcp_ado_testplan_create_test_suite](#mcp_ado_testplan_create_test_suite)                                 | Create a test suite within a test plan                   |
 | 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                           |
 | 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.
 - **Required**: `project`, `name`, `iteration`
 - **Optional**: `areaPath`, `description`, `endDate`, `startDate`
 
+### mcp_ado_testplan_list_test_suites
+
+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.
+
+- **Required**: `project`, `planId`
+- **Optional**: `continuationToken`
+
 ### mcp_ado_testplan_create_test_suite
 
 Creates a new test suite in a test plan.
diff --git a/src/tools/test-plans.ts b/src/tools/test-plans.ts
index 8a7ae80..79b8054 100644
--- a/src/tools/test-plans.ts
+++ b/src/tools/test-plans.ts
@@ -3,7 +3,7 @@
 
 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
 import { WebApi } from "azure-devops-node-api";
-import { TestPlanCreateParams } from "azure-devops-node-api/interfaces/TestPlanInterfaces.js";
+import { SuiteExpand, TestPlanCreateParams } from "azure-devops-node-api/interfaces/TestPlanInterfaces.js";
 import { z } from "zod";
 
 const Test_Plan_Tools = {
@@ -14,6 +14,7 @@ const Test_Plan_Tools = {
   test_results_from_build_id: "testplan_show_test_results_from_build_id",
   list_test_cases: "testplan_list_test_cases",
   list_test_plans: "testplan_list_test_plans",
+  list_test_suites: "testplan_list_test_suites",
   create_test_suite: "testplan_create_test_suite",
 };
 
@@ -352,6 +353,76 @@ function configureTestPlanTools(server: McpServer, _: () => Promise<string>, con
       }
     }
   );
+
+  server.tool(
+    Test_Plan_Tools.list_test_suites,
+    "Retrieve a paginated list of test suites from an Azure DevOps project and Test Plan Id.",
+    {
+      project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
+      planId: z.number().describe("The ID of the test plan."),
+      continuationToken: z.string().optional().describe("Token to continue fetching test plans from a previous request."),
+    },
+    async ({ project, planId, continuationToken }) => {
+      try {
+        const connection = await connectionProvider();
+        const testPlanApi = await connection.getTestPlanApi();
+        const expand: SuiteExpand = SuiteExpand.Children;
+
+        const testSuites = await testPlanApi.getTestSuitesForPlan(project, planId, expand, continuationToken);
+
+        // The API returns a flat list where the root suite is first, followed by all nested suites
+        // We need to build a proper hierarchy by creating a map and assembling the tree
+
+        // Create a map of all suites by ID for quick lookup
+        const suiteMap = new Map();
+        testSuites.forEach((suite: any) => {
+          suiteMap.set(suite.id, {
+            id: suite.id,
+            name: suite.name,
+            parentSuiteId: suite.parentSuite?.id,
+            children: [] as any[],
+          });
+        });
+
+        // Build the hierarchy by linking children to parents
+        const roots: any[] = [];
+        suiteMap.forEach((suite: any) => {
+          if (suite.parentSuiteId && suiteMap.has(suite.parentSuiteId)) {
+            // This is a child suite, add it to its parent's children array
+            const parent = suiteMap.get(suite.parentSuiteId);
+            parent.children.push(suite);
+          } else {
+            // This is a root suite (no parent or parent not in map)
+            roots.push(suite);
+          }
+        });
+
+        // Clean up the output - remove parentSuiteId and empty children arrays
+        const cleanSuite = (suite: any): any => {
+          const cleaned: any = {
+            id: suite.id,
+            name: suite.name,
+          };
+          if (suite.children && suite.children.length > 0) {
+            cleaned.children = suite.children.map((child: any) => cleanSuite(child));
+          }
+          return cleaned;
+        };
+
+        const result = roots.map((root: any) => cleanSuite(root));
+
+        return {
+          content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+        };
+      } catch (error) {
+        const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
+        return {
+          content: [{ type: "text", text: `Error listing test suites: ${errorMessage}` }],
+          isError: true,
+        };
+      }
+    }
+  );
 }
 
 /*



Test patch:
diff --git a/test/src/tools/test-plan.test.ts b/test/src/tools/test-plan.test.ts
index 9a0bb4e..0c7eb59 100644
--- a/test/src/tools/test-plan.test.ts
+++ b/test/src/tools/test-plan.test.ts
@@ -72,6 +72,7 @@ describe("configureTestPlanTools", () => {
           "testplan_update_test_case_steps",
           "testplan_list_test_cases",
           "testplan_show_test_results_from_build_id",
+          "testplan_list_test_suites",
         ])
       );
     });
@@ -118,6 +119,269 @@ describe("configureTestPlanTools", () => {
     });
   });
 
+  describe("list_test_suites tool", () => {
+    beforeEach(() => {
+      (mockTestPlanApi as any).getTestSuitesForPlan = jest.fn();
+    });
+
+    it("should call getTestSuitesForPlan and return properly nested hierarchy", async () => {
+      configureTestPlanTools(server, tokenProvider, connectionProvider);
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites");
+      if (!call) throw new Error("testplan_list_test_suites tool not registered");
+      const [, , , handler] = call;
+
+      // Mock API response with flat list including nested suites
+      ((mockTestPlanApi as any).getTestSuitesForPlan as jest.Mock).mockResolvedValue([
+        {
+          id: 100,
+          name: "Root Suite",
+          hasChildren: true,
+          children: [
+            { id: 101, name: "Child Suite 1", parentSuite: { id: 100 } },
+            { id: 102, name: "Child Suite 2", parentSuite: { id: 100 } },
+          ],
+        },
+        {
+          id: 101,
+          name: "Child Suite 1",
+          hasChildren: true,
+          parentSuite: { id: 100 },
+          children: [{ id: 103, name: "Grandchild Suite", parentSuite: { id: 101 } }],
+        },
+        {
+          id: 102,
+          name: "Child Suite 2",
+          parentSuite: { id: 100 },
+        },
+        {
+          id: 103,
+          name: "Grandchild Suite",
+          parentSuite: { id: 101 },
+        },
+      ]);
+
+      const params = {
+        project: "proj1",
+        planId: 1,
+      };
+      const result = await handler(params);
+
+      expect((mockTestPlanApi as any).getTestSuitesForPlan).toHaveBeenCalledWith("proj1", 1, 1, undefined);
+
+      // Parse and validate the nested structure
+      const parsed = JSON.parse(result.content[0].text);
+      expect(parsed).toHaveLength(1);
+      expect(parsed[0]).toMatchObject({
+        id: 100,
+        name: "Root Suite",
+        children: [
+          {
+            id: 101,
+            name: "Child Suite 1",
+            children: [
+              {
+                id: 103,
+                name: "Grandchild Suite",
+              },
+            ],
+          },
+          {
+            id: 102,
+            name: "Child Suite 2",
+          },
+        ],
+      });
+    });
+
+    it("should handle test suite with no children", async () => {
+      configureTestPlanTools(server, tokenProvider, connectionProvider);
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites");
+      if (!call) throw new Error("testplan_list_test_suites tool not registered");
+      const [, , , handler] = call;
+
+      ((mockTestPlanApi as any).getTestSuitesForPlan as jest.Mock).mockResolvedValue([
+        {
+          id: 200,
+          name: "Single Suite",
+          hasChildren: false,
+        },
+      ]);
+
+      const params = {
+        project: "proj1",
+        planId: 2,
+      };
+      const result = await handler(params);
+
+      const parsed = JSON.parse(result.content[0].text);
+      expect(parsed).toHaveLength(1);
+      expect(parsed[0]).toEqual({
+        id: 200,
+        name: "Single Suite",
+      });
+    });
+
+    it("should handle empty test suite list", async () => {
+      configureTestPlanTools(server, tokenProvider, connectionProvider);
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites");
+      if (!call) throw new Error("testplan_list_test_suites tool not registered");
+      const [, , , handler] = call;
+
+      ((mockTestPlanApi as any).getTestSuitesForPlan as jest.Mock).mockResolvedValue([]);
+
+      const params = {
+        project: "proj1",
+        planId: 3,
+      };
+      const result = await handler(params);
+
+      const parsed = JSON.parse(result.content[0].text);
+      expect(parsed).toEqual([]);
+    });
+
+    it("should handle deeply nested suite hierarchy", async () => {
+      configureTestPlanTools(server, tokenProvider, connectionProvider);
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites");
+      if (!call) throw new Error("testplan_list_test_suites tool not registered");
+      const [, , , handler] = call;
+
+      // Mock a deeply nested structure
+      ((mockTestPlanApi as any).getTestSuitesForPlan as jest.Mock).mockResolvedValue([
+        {
+          id: 300,
+          name: "Root",
+          hasChildren: true,
+          children: [{ id: 301, name: "Level 1", parentSuite: { id: 300 } }],
+        },
+        {
+          id: 301,
+          name: "Level 1",
+          hasChildren: true,
+          parentSuite: { id: 300 },
+          children: [{ id: 302, name: "Level 2", parentSuite: { id: 301 } }],
+        },
+        {
+          id: 302,
+          name: "Level 2",
+          hasChildren: true,
+          parentSuite: { id: 301 },
+          children: [{ id: 303, name: "Level 3", parentSuite: { id: 302 } }],
+        },
+        {
+          id: 303,
+          name: "Level 3",
+          parentSuite: { id: 302 },
+        },
+      ]);
+
+      const params = {
+        project: "proj1",
+        planId: 4,
+      };
+      const result = await handler(params);
+
+      const parsed = JSON.parse(result.content[0].text);
+      expect(parsed[0]).toMatchObject({
+        id: 300,
+        name: "Root",
+        children: [
+          {
+            id: 301,
+            name: "Level 1",
+            children: [
+              {
+                id: 302,
+                name: "Level 2",
+                children: [
+                  {
+                    id: 303,
+                    name: "Level 3",
+                  },
+                ],
+              },
+            ],
+          },
+        ],
+      });
+    });
+
+    it("should handle API errors when listing test suites", async () => {
+      configureTestPlanTools(server, tokenProvider, connectionProvider);
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites");
+      if (!call) throw new Error("testplan_list_test_suites tool not registered");
+      const [, , , handler] = call;
+
+      ((mockTestPlanApi as any).getTestSuitesForPlan as jest.Mock).mockRejectedValue(new Error("API Error"));
+
+      const params = {
+        project: "proj1",
+        planId: 5,
+      };
+      const result = await handler(params);
+
+      expect(result.isError).toBe(true);
+      expect(result.content[0].text).toContain("Error listing test suites: API Error");
+    });
+
+    it("should pass continuation token when provided", async () => {
+      configureTestPlanTools(server, tokenProvider, connectionProvider);
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites");
+      if (!call) throw new Error("testplan_list_test_suites tool not registered");
+      const [, , , handler] = call;
+
+      ((mockTestPlanApi as any).getTestSuitesForPlan as jest.Mock).mockResolvedValue([
+        {
+          id: 400,
+          name: "Suite with Token",
+        },
+      ]);
+
+      const params = {
+        project: "proj1",
+        planId: 6,
+        continuationToken: "token123",
+      };
+      await handler(params);
+
+      expect((mockTestPlanApi as any).getTestSuitesForPlan).toHaveBeenCalledWith("proj1", 6, 1, "token123");
+    });
+
+    it("should not include empty children arrays in output", async () => {
+      configureTestPlanTools(server, tokenProvider, connectionProvider);
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites");
+      if (!call) throw new Error("testplan_list_test_suites tool not registered");
+      const [, , , handler] = call;
+
+      ((mockTestPlanApi as any).getTestSuitesForPlan as jest.Mock).mockResolvedValue([
+        {
+          id: 500,
+          name: "Parent",
+          hasChildren: true,
+          children: [{ id: 501, name: "Child with no children", parentSuite: { id: 500 } }],
+        },
+        {
+          id: 501,
+          name: "Child with no children",
+          parentSuite: { id: 500 },
+          hasChildren: false,
+        },
+      ]);
+
+      const params = {
+        project: "proj1",
+        planId: 7,
+      };
+      const result = await handler(params);
+
+      const parsed = JSON.parse(result.content[0].text);
+      expect(parsed[0].children[0]).toEqual({
+        id: 501,
+        name: "Child with no children",
+      });
+      expect(parsed[0].children[0].children).toBeUndefined();
+    });
+  });
+
   describe("create_test_plan tool", () => {
     it("should call createTestPlan with the correct parameters and return the expected result", async () => {
       configureTestPlanTools(server, tokenProvider, connectionProvider);