Skip to content

Commit 511856c

Browse files
krid-583Krishna Prasath Ddanhellem
authored
Continuation token fix for list tools in test plans (#1110)
- In the list tools of test plans: list_test_plans tool, list_test_suites tool and list_test_cases tool; The underlying function does not propogate the continuation token even when more test artifacts need to be fetched. - The default pagination for these tools is 200 and only if the continuation token is propogated can the rest be fetched. - The list_test_cases tool did not have continuation token implemented in it previously, implemented that. Changes: 1. Updated the list tools in test plans to use Test plans REST Api for propagating continuation tokens correctly. 2. Added the optional parameter 'continuation token' to list_test_cases tool. 3. Updated and Added unit tests corresponding to these tools. ## GitHub issue number 1069 ## **Associated Risks** - when the list of test plans or test cases that need to be fetched are too large, there are too many mcp calls that need to be made since the pagination size is 200 only. - Also the response size of the rest API's are usually large and the chat needs to make a seperate call to read the response from the file where it is stored. ## ✅ **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 - [N/A] 🔭 Telemetry added, updated, or N/A - [N/A] 📄 Documentation added, updated, or N/A - [X] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** Added unit tests to check for continuation token related scenarios. Manually tested using prompts --------- Co-authored-by: Krishna Prasath D <krid@microsoft.com> Co-authored-by: Dan Hellem <dahellem@microsoft.com>
1 parent 63f76d6 commit 511856c

4 files changed

Lines changed: 292 additions & 96 deletions

File tree

docs/TOOLSET.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,7 @@ Adds existing test cases to a test suite.
454454
Gets a list of test cases in the test plan.
455455

456456
- **Required**: `project`, `planid`, `suiteid`
457-
- **Optional**: None
457+
- **Optional**: `continuationToken`
458458

459459
### mcp_ado_testplan_create_test_case
460460

src/tools.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ function configureAllTools(server: McpServer, tokenProvider: () => Promise<strin
3030
configureIfDomainEnabled(Domain.REPOSITORIES, () => configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider));
3131
configureIfDomainEnabled(Domain.WORK_ITEMS, () => configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider));
3232
configureIfDomainEnabled(Domain.WIKI, () => configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider));
33-
configureIfDomainEnabled(Domain.TEST_PLANS, () => configureTestPlanTools(server, tokenProvider, connectionProvider));
33+
configureIfDomainEnabled(Domain.TEST_PLANS, () => configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider));
3434
configureIfDomainEnabled(Domain.SEARCH, () => configureSearchTools(server, tokenProvider, connectionProvider, userAgentProvider));
3535
configureIfDomainEnabled(Domain.ADVANCED_SECURITY, () => configureAdvSecTools(server, tokenProvider, connectionProvider));
3636
}

src/tools/test-plans.ts

Lines changed: 107 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33

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

910
const Test_Plan_Tools = {
1011
create_test_plan: "testplan_create_test_plan",
@@ -18,7 +19,7 @@ const Test_Plan_Tools = {
1819
create_test_suite: "testplan_create_test_suite",
1920
};
2021

21-
function configureTestPlanTools(server: McpServer, _: () => Promise<string>, connectionProvider: () => Promise<WebApi>) {
22+
function configureTestPlanTools(server: McpServer, tokenProvider: () => Promise<string>, connectionProvider: () => Promise<WebApi>, userAgentProvider?: () => string) {
2223
server.tool(
2324
Test_Plan_Tools.list_test_plans,
2425
"Retrieve a paginated list of test plans from an Azure DevOps project. Allows filtering for active plans and toggling detailed information.",
@@ -30,14 +31,45 @@ function configureTestPlanTools(server: McpServer, _: () => Promise<string>, con
3031
},
3132
async ({ project, filterActivePlans, includePlanDetails, continuationToken }) => {
3233
try {
33-
const owner = ""; //making owner an empty string untill we can figure out how to get owner id
3434
const connection = await connectionProvider();
35-
const testPlanApi = await connection.getTestPlanApi();
35+
const accessToken = await tokenProvider();
36+
const params = new URLSearchParams({ "api-version": apiVersion });
37+
if (filterActivePlans) params.append("filterActivePlans", "true");
38+
if (includePlanDetails) params.append("includePlanDetails", "true");
39+
if (continuationToken) params.append("continuationToken", continuationToken);
40+
const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans?${params.toString()}`;
41+
const headers: Record<string, string> = {
42+
Authorization: `Bearer ${accessToken}`,
43+
};
44+
45+
const userAgent = userAgentProvider?.();
46+
if (userAgent) {
47+
headers["User-Agent"] = userAgent;
48+
}
49+
50+
const response = await fetch(url, {
51+
method: "GET",
52+
headers,
53+
});
54+
55+
if (!response.ok) {
56+
const errorText = await response.text();
57+
throw new Error(`Failed to list test plans (${response.status}): ${errorText}`);
58+
}
59+
60+
const body = await response.json();
61+
const testPlans = body.value ?? [];
62+
const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
3663

37-
const testPlans = await testPlanApi.getTestPlans(project, owner, continuationToken, includePlanDetails, filterActivePlans);
64+
const result: { testPlans: typeof testPlans; continuationToken?: string } = {
65+
testPlans: testPlans,
66+
};
67+
if (nextToken) {
68+
result.continuationToken = nextToken;
69+
}
3870

3971
return {
40-
content: [{ type: "text", text: JSON.stringify(testPlans, null, 2) }],
72+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
4173
};
4274
} catch (error) {
4375
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
@@ -331,15 +363,47 @@ function configureTestPlanTools(server: McpServer, _: () => Promise<string>, con
331363
project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
332364
planid: z.coerce.number().min(1).describe("The ID of the test plan."),
333365
suiteid: z.coerce.number().min(1).describe("The ID of the test suite."),
366+
continuationToken: z.string().optional().describe("Token to continue fetching test cases from a previous request."),
334367
},
335-
async ({ project, planid, suiteid }) => {
368+
async ({ project, planid, suiteid, continuationToken }) => {
336369
try {
337370
const connection = await connectionProvider();
338-
const coreApi = await connection.getTestPlanApi();
339-
const testcases = await coreApi.getTestCaseList(project, planid, suiteid);
371+
const accessToken = await tokenProvider();
372+
const params = new URLSearchParams({ "api-version": "7.2-preview.3" });
373+
if (continuationToken) params.append("continuationToken", continuationToken);
374+
const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans/${planid}/Suites/${suiteid}/TestCase?${params.toString()}`;
375+
const headers: Record<string, string> = {
376+
Authorization: `Bearer ${accessToken}`,
377+
};
378+
379+
const userAgent = userAgentProvider?.();
380+
if (userAgent) {
381+
headers["User-Agent"] = userAgent;
382+
}
383+
384+
const response = await fetch(url, {
385+
method: "GET",
386+
headers,
387+
});
388+
389+
if (!response.ok) {
390+
const errorText = await response.text();
391+
throw new Error(`Failed to list test cases (${response.status}): ${errorText}`);
392+
}
393+
394+
const body = await response.json();
395+
const testcases = body.value ?? [];
396+
const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
397+
398+
const result: { testCases: typeof testcases; continuationToken?: string } = {
399+
testCases: testcases,
400+
};
401+
if (nextToken) {
402+
result.continuationToken = nextToken;
403+
}
340404

341405
return {
342-
content: [{ type: "text", text: JSON.stringify(testcases, null, 2) }],
406+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
343407
};
344408
} catch (error) {
345409
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
@@ -427,10 +491,32 @@ function configureTestPlanTools(server: McpServer, _: () => Promise<string>, con
427491
async ({ project, planId, continuationToken }) => {
428492
try {
429493
const connection = await connectionProvider();
430-
const testPlanApi = await connection.getTestPlanApi();
431-
const expand: SuiteExpand = SuiteExpand.Children;
494+
const accessToken = await tokenProvider();
495+
const params = new URLSearchParams({ "api-version": apiVersion, "expand": "children" });
496+
if (continuationToken) params.append("continuationToken", continuationToken);
497+
const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans/${planId}/Suites?${params.toString()}`;
498+
const headers: Record<string, string> = {
499+
Authorization: `Bearer ${accessToken}`,
500+
};
432501

433-
const testSuites = await testPlanApi.getTestSuitesForPlan(project, planId, expand, continuationToken);
502+
const userAgent = userAgentProvider?.();
503+
if (userAgent) {
504+
headers["User-Agent"] = userAgent;
505+
}
506+
507+
const response = await fetch(url, {
508+
method: "GET",
509+
headers,
510+
});
511+
512+
if (!response.ok) {
513+
const errorText = await response.text();
514+
throw new Error(`Failed to list test suites (${response.status}): ${errorText}`);
515+
}
516+
517+
const body = await response.json();
518+
const testSuites = body.value ?? [];
519+
const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
434520

435521
// The API returns a flat list where the root suite is first, followed by all nested suites
436522
// We need to build a proper hierarchy by creating a map and assembling the tree
@@ -471,7 +557,14 @@ function configureTestPlanTools(server: McpServer, _: () => Promise<string>, con
471557
return cleaned;
472558
};
473559

474-
const result = roots.map((root: any) => cleanSuite(root));
560+
const cleanedSuites = roots.map((root: any) => cleanSuite(root));
561+
562+
const result: { testSuites: typeof cleanedSuites; continuationToken?: string } = {
563+
testSuites: cleanedSuites,
564+
};
565+
if (nextToken) {
566+
result.continuationToken = nextToken;
567+
}
475568

476569
return {
477570
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],

0 commit comments

Comments
 (0)