@sonyjv/azure-devops-mcp 2.9.0-onprem.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.
- package/LICENSE.md +21 -0
- package/README.md +234 -0
- package/dist/auth.js +205 -0
- package/dist/index.js +116 -0
- package/dist/logger.js +34 -0
- package/dist/org-tenants.js +76 -0
- package/dist/prompts.js +20 -0
- package/dist/shared/content-safety.js +24 -0
- package/dist/shared/domains.js +130 -0
- package/dist/shared/elicitations.js +72 -0
- package/dist/shared/tool-validation.js +92 -0
- package/dist/tools/advanced-security.js +128 -0
- package/dist/tools/auth.js +66 -0
- package/dist/tools/core.js +103 -0
- package/dist/tools/mcp-apps.js +22 -0
- package/dist/tools/pipelines.dto.js +103 -0
- package/dist/tools/pipelines.js +401 -0
- package/dist/tools/repositories.js +941 -0
- package/dist/tools/search.js +188 -0
- package/dist/tools/test-plans.js +440 -0
- package/dist/tools/wiki.js +381 -0
- package/dist/tools/work-items.js +1130 -0
- package/dist/tools/work.js +345 -0
- package/dist/tools.js +31 -0
- package/dist/useragent.js +20 -0
- package/dist/utils.js +173 -0
- package/dist/version.js +1 -0
- package/package.json +80 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Copyright (c) Microsoft Corporation.
|
|
2
|
+
// Licensed under the MIT License.
|
|
3
|
+
import { VersionControlRecursionType } from "azure-devops-node-api/interfaces/GitInterfaces.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { apiVersion } from "../utils.js";
|
|
6
|
+
import { orgName } from "../index.js";
|
|
7
|
+
import { createExternalContentResponse } from "../shared/content-safety.js";
|
|
8
|
+
const SEARCH_TOOLS = {
|
|
9
|
+
search_code: "search_code",
|
|
10
|
+
search_wiki: "search_wiki",
|
|
11
|
+
search_workitem: "search_workitem",
|
|
12
|
+
};
|
|
13
|
+
function configureSearchTools(server, tokenProvider, connectionProvider, userAgentProvider) {
|
|
14
|
+
server.tool(SEARCH_TOOLS.search_code, "Search Azure DevOps Repositories for a given search text", {
|
|
15
|
+
searchText: z.string().describe("Keywords to search for in code repositories"),
|
|
16
|
+
project: z
|
|
17
|
+
.union([z.string().transform((value) => [value]), z.array(z.string())])
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("Filter by projects"),
|
|
20
|
+
repository: z.array(z.string()).optional().describe("Filter by repositories"),
|
|
21
|
+
path: z.array(z.string()).optional().describe("Filter by paths"),
|
|
22
|
+
branch: z.array(z.string()).optional().describe("Filter by branches"),
|
|
23
|
+
includeFacets: z.boolean().default(false).describe("Include facets in the search results"),
|
|
24
|
+
skip: z.coerce.number().default(0).describe("Number of results to skip"),
|
|
25
|
+
top: z.coerce.number().default(5).describe("Maximum number of results to return"),
|
|
26
|
+
}, async ({ searchText, project, repository, path, branch, includeFacets, skip, top }) => {
|
|
27
|
+
const accessToken = await tokenProvider();
|
|
28
|
+
const connection = await connectionProvider();
|
|
29
|
+
const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/codesearchresults?api-version=${apiVersion}`;
|
|
30
|
+
const requestBody = {
|
|
31
|
+
searchText,
|
|
32
|
+
includeFacets,
|
|
33
|
+
$skip: skip,
|
|
34
|
+
$top: top,
|
|
35
|
+
};
|
|
36
|
+
const filters = {};
|
|
37
|
+
if (project && project.length > 0)
|
|
38
|
+
filters.Project = project;
|
|
39
|
+
if (repository && repository.length > 0)
|
|
40
|
+
filters.Repository = repository;
|
|
41
|
+
if (path && path.length > 0)
|
|
42
|
+
filters.Path = path;
|
|
43
|
+
if (branch && branch.length > 0)
|
|
44
|
+
filters.Branch = branch;
|
|
45
|
+
if (Object.keys(filters).length > 0) {
|
|
46
|
+
requestBody.filters = filters;
|
|
47
|
+
}
|
|
48
|
+
const response = await fetch(url, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: {
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
53
|
+
"User-Agent": userAgentProvider(),
|
|
54
|
+
},
|
|
55
|
+
body: JSON.stringify(requestBody),
|
|
56
|
+
});
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new Error(`Azure DevOps Code Search API error: ${response.status} ${response.statusText}`);
|
|
59
|
+
}
|
|
60
|
+
const resultText = await response.text();
|
|
61
|
+
const resultJson = JSON.parse(resultText);
|
|
62
|
+
const gitApi = await connection.getGitApi();
|
|
63
|
+
const combinedResults = await fetchCombinedResults(resultJson.results ?? [], gitApi);
|
|
64
|
+
return createExternalContentResponse(resultText + JSON.stringify(combinedResults), "code search results");
|
|
65
|
+
});
|
|
66
|
+
server.tool(SEARCH_TOOLS.search_wiki, "Search Azure DevOps Wiki for a given search text", {
|
|
67
|
+
searchText: z.string().describe("Keywords to search for wiki pages"),
|
|
68
|
+
project: z.array(z.string()).optional().describe("Filter by projects"),
|
|
69
|
+
wiki: z.array(z.string()).optional().describe("Filter by wiki names"),
|
|
70
|
+
includeFacets: z.boolean().default(false).describe("Include facets in the search results"),
|
|
71
|
+
skip: z.coerce.number().default(0).describe("Number of results to skip"),
|
|
72
|
+
top: z.coerce.number().default(10).describe("Maximum number of results to return"),
|
|
73
|
+
}, async ({ searchText, project, wiki, includeFacets, skip, top }) => {
|
|
74
|
+
const accessToken = await tokenProvider();
|
|
75
|
+
const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/wikisearchresults?api-version=${apiVersion}`;
|
|
76
|
+
const requestBody = {
|
|
77
|
+
searchText,
|
|
78
|
+
includeFacets,
|
|
79
|
+
$skip: skip,
|
|
80
|
+
$top: top,
|
|
81
|
+
};
|
|
82
|
+
const filters = {};
|
|
83
|
+
if (project && project.length > 0)
|
|
84
|
+
filters.Project = project;
|
|
85
|
+
if (wiki && wiki.length > 0)
|
|
86
|
+
filters.Wiki = wiki;
|
|
87
|
+
if (Object.keys(filters).length > 0) {
|
|
88
|
+
requestBody.filters = filters;
|
|
89
|
+
}
|
|
90
|
+
const response = await fetch(url, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: {
|
|
93
|
+
"Content-Type": "application/json",
|
|
94
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
95
|
+
"User-Agent": userAgentProvider(),
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify(requestBody),
|
|
98
|
+
});
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
throw new Error(`Azure DevOps Wiki Search API error: ${response.status} ${response.statusText}`);
|
|
101
|
+
}
|
|
102
|
+
const result = await response.text();
|
|
103
|
+
return createExternalContentResponse(result, "wiki search results");
|
|
104
|
+
});
|
|
105
|
+
server.tool(SEARCH_TOOLS.search_workitem, "Get Azure DevOps Work Item search results for a given search text", {
|
|
106
|
+
searchText: z.string().describe("Search text to find in work items"),
|
|
107
|
+
project: z.array(z.string()).optional().describe("Filter by projects"),
|
|
108
|
+
areaPath: z.array(z.string()).optional().describe("Filter by area paths"),
|
|
109
|
+
workItemType: z.array(z.string()).optional().describe("Filter by work item types"),
|
|
110
|
+
state: z.array(z.string()).optional().describe("Filter by work item states"),
|
|
111
|
+
assignedTo: z.array(z.string()).optional().describe("Filter by assigned to users"),
|
|
112
|
+
includeFacets: z.boolean().default(false).describe("Include facets in the search results"),
|
|
113
|
+
skip: z.coerce.number().default(0).describe("Number of results to skip for pagination"),
|
|
114
|
+
top: z.coerce.number().default(10).describe("Number of results to return"),
|
|
115
|
+
}, async ({ searchText, project, areaPath, workItemType, state, assignedTo, includeFacets, skip, top }) => {
|
|
116
|
+
const accessToken = await tokenProvider();
|
|
117
|
+
const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/workitemsearchresults?api-version=${apiVersion}`;
|
|
118
|
+
const requestBody = {
|
|
119
|
+
searchText,
|
|
120
|
+
includeFacets,
|
|
121
|
+
$skip: skip,
|
|
122
|
+
$top: top,
|
|
123
|
+
};
|
|
124
|
+
const filters = {};
|
|
125
|
+
if (project && project.length > 0)
|
|
126
|
+
filters["System.TeamProject"] = project;
|
|
127
|
+
if (areaPath && areaPath.length > 0)
|
|
128
|
+
filters["System.AreaPath"] = areaPath;
|
|
129
|
+
if (workItemType && workItemType.length > 0)
|
|
130
|
+
filters["System.WorkItemType"] = workItemType;
|
|
131
|
+
if (state && state.length > 0)
|
|
132
|
+
filters["System.State"] = state;
|
|
133
|
+
if (assignedTo && assignedTo.length > 0)
|
|
134
|
+
filters["System.AssignedTo"] = assignedTo;
|
|
135
|
+
if (Object.keys(filters).length > 0) {
|
|
136
|
+
requestBody.filters = filters;
|
|
137
|
+
}
|
|
138
|
+
const response = await fetch(url, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: {
|
|
141
|
+
"Content-Type": "application/json",
|
|
142
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
143
|
+
"User-Agent": userAgentProvider(),
|
|
144
|
+
},
|
|
145
|
+
body: JSON.stringify(requestBody),
|
|
146
|
+
});
|
|
147
|
+
if (!response.ok) {
|
|
148
|
+
throw new Error(`Azure DevOps Work Item Search API error: ${response.status} ${response.statusText}`);
|
|
149
|
+
}
|
|
150
|
+
const result = await response.text();
|
|
151
|
+
return createExternalContentResponse(result, "work item search results");
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
async function fetchCombinedResults(topSearchResults, gitApi) {
|
|
155
|
+
const combinedResults = [];
|
|
156
|
+
for (const searchResult of topSearchResults) {
|
|
157
|
+
try {
|
|
158
|
+
const projectId = searchResult.project?.id;
|
|
159
|
+
const repositoryId = searchResult.repository?.id;
|
|
160
|
+
const filePath = searchResult.path;
|
|
161
|
+
const changeId = Array.isArray(searchResult.versions) && searchResult.versions.length > 0 ? searchResult.versions[0].changeId : undefined;
|
|
162
|
+
if (!projectId || !repositoryId || !filePath || !changeId) {
|
|
163
|
+
combinedResults.push({
|
|
164
|
+
error: `Missing projectId, repositoryId, filePath, or changeId in the result: ${JSON.stringify(searchResult)}`,
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const versionDescriptor = { version: changeId, versionType: 2, versionOptions: 0 };
|
|
169
|
+
const item = await gitApi.getItem(repositoryId, filePath, projectId, undefined, VersionControlRecursionType.None, true, // includeContentMetadata
|
|
170
|
+
false, // latestProcessedChange
|
|
171
|
+
false, // download
|
|
172
|
+
versionDescriptor, true, // includeContent
|
|
173
|
+
true, // resolveLfs
|
|
174
|
+
true // sanitize
|
|
175
|
+
);
|
|
176
|
+
combinedResults.push({
|
|
177
|
+
gitItem: item,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
combinedResults.push({
|
|
182
|
+
error: err instanceof Error ? err.message : String(err),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return combinedResults;
|
|
187
|
+
}
|
|
188
|
+
export { SEARCH_TOOLS, configureSearchTools };
|
|
@@ -0,0 +1,440 @@
|
|
|
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
|
+
testplan: "testplan",
|
|
7
|
+
test_results_from_build_id: "testplan_show_test_results_from_build_id",
|
|
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",
|
|
11
|
+
};
|
|
12
|
+
function configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider) {
|
|
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
|
+
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. 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 }) => {
|
|
25
|
+
try {
|
|
26
|
+
const connection = await connectionProvider();
|
|
27
|
+
const accessToken = await tokenProvider();
|
|
28
|
+
const headers = {
|
|
29
|
+
Authorization: `Bearer ${accessToken}`,
|
|
30
|
+
};
|
|
31
|
+
const userAgent = userAgentProvider?.();
|
|
32
|
+
if (userAgent) {
|
|
33
|
+
headers["User-Agent"] = userAgent;
|
|
34
|
+
}
|
|
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}`);
|
|
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 = { testPlans };
|
|
53
|
+
if (nextToken)
|
|
54
|
+
result.continuationToken = nextToken;
|
|
55
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
56
|
+
}
|
|
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
|
+
});
|
|
80
|
+
});
|
|
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
|
+
}
|
|
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) }] };
|
|
102
|
+
}
|
|
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) }] };
|
|
124
|
+
}
|
|
125
|
+
return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
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";
|
|
130
|
+
return {
|
|
131
|
+
content: [{ type: "text", text: `${prefix}: ${errorMessage}` }],
|
|
132
|
+
isError: true,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
});
|
|
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.", {
|
|
138
|
+
project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
|
|
139
|
+
buildid: z.coerce.number().min(1).describe("The ID of the build."),
|
|
140
|
+
outcomes: z.array(z.string()).optional().describe("Filter results by test outcome, e.g. ['Failed', 'Passed', 'Aborted']."),
|
|
141
|
+
}, async ({ project, buildid, outcomes }) => {
|
|
142
|
+
try {
|
|
143
|
+
const connection = await connectionProvider();
|
|
144
|
+
const testResultsApi = await connection.getTestResultsApi();
|
|
145
|
+
const outcomeFilter = outcomes?.length ? `Outcome eq ${outcomes.join(",")}` : undefined;
|
|
146
|
+
const testResultDetails = await testResultsApi.getTestResultDetailsForBuild(project, buildid, undefined, undefined, outcomeFilter, undefined, true);
|
|
147
|
+
const allResults = [];
|
|
148
|
+
if (testResultDetails.resultsForGroup) {
|
|
149
|
+
for (const group of testResultDetails.resultsForGroup) {
|
|
150
|
+
if (group.results) {
|
|
151
|
+
for (const result of group.results) {
|
|
152
|
+
allResults.push(result);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const formattedResults = allResults.map((r) => ({
|
|
158
|
+
id: r.id,
|
|
159
|
+
testCaseTitle: r.testCaseTitle,
|
|
160
|
+
outcome: r.outcome,
|
|
161
|
+
errorMessage: r.errorMessage,
|
|
162
|
+
stackTrace: r.stackTrace,
|
|
163
|
+
automatedTestName: r.automatedTestName,
|
|
164
|
+
automatedTestStorage: r.automatedTestStorage,
|
|
165
|
+
durationInMs: r.durationInMs,
|
|
166
|
+
runId: r.testRun?.id,
|
|
167
|
+
}));
|
|
168
|
+
return {
|
|
169
|
+
content: [{ type: "text", text: JSON.stringify(formattedResults, null, 2) }],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
174
|
+
return {
|
|
175
|
+
content: [{ type: "text", text: `Error fetching test results: ${errorMessage}` }],
|
|
176
|
+
isError: true,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
});
|
|
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)."),
|
|
183
|
+
project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
|
|
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 }) => {
|
|
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 };
|
|
196
|
+
const connection = await connectionProvider();
|
|
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,
|
|
205
|
+
};
|
|
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
|
+
};
|
|
274
|
+
}
|
|
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
|
+
};
|
|
289
|
+
}
|
|
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);
|
|
326
|
+
}
|
|
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
|
+
});
|
|
338
|
+
}
|
|
339
|
+
if (stepsXml) {
|
|
340
|
+
patchDocument.push({ op: "add", path: "/fields/Microsoft.VSTS.TCM.Steps", value: stepsXml });
|
|
341
|
+
}
|
|
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
|
+
};
|
|
355
|
+
}
|
|
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 };
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
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";
|
|
376
|
+
return {
|
|
377
|
+
content: [{ type: "text", text: `${prefix}: ${errorMessage}` }],
|
|
378
|
+
isError: true,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
/*
|
|
384
|
+
* Format step content by converting Markdown markers to HTML and wrapping in the ADO rich text
|
|
385
|
+
* envelope. The entire HTML string is then XML-escaped for storage in the parameterizedString
|
|
386
|
+
* element, which is the format Azure DevOps expects for rendered step content.
|
|
387
|
+
*/
|
|
388
|
+
function formatStepContent(text) {
|
|
389
|
+
const htmlContent = text
|
|
390
|
+
.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
|
|
391
|
+
.replace(/\*(.+?)\*/g, "<i>$1</i>")
|
|
392
|
+
.replace(/__(.+?)__/g, "<u>$1</u>")
|
|
393
|
+
.replace(/`(.+?)`/g, "<code>$1</code>")
|
|
394
|
+
.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2">$1</a>');
|
|
395
|
+
return escapeXml(`${htmlContent}`);
|
|
396
|
+
}
|
|
397
|
+
/*
|
|
398
|
+
* Helper function to convert steps text to XML format required
|
|
399
|
+
*/
|
|
400
|
+
function convertStepsToXml(steps) {
|
|
401
|
+
const stepsLines = steps.split("\n").filter((line) => line.trim() !== "");
|
|
402
|
+
let xmlSteps = `<steps id="0" last="${stepsLines.length}">`;
|
|
403
|
+
for (let i = 0; i < stepsLines.length; i++) {
|
|
404
|
+
const stepLine = stepsLines[i].trim();
|
|
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 += `
|
|
410
|
+
<step id="${i + 1}" type="ActionStep">
|
|
411
|
+
<parameterizedString isformatted="true">${formatStepContent(stepText)}</parameterizedString>
|
|
412
|
+
<parameterizedString isformatted="true">${formatStepContent(expectedText)}</parameterizedString>
|
|
413
|
+
</step>`;
|
|
414
|
+
}
|
|
415
|
+
xmlSteps += "</steps>";
|
|
416
|
+
return xmlSteps;
|
|
417
|
+
}
|
|
418
|
+
/*
|
|
419
|
+
* Helper function to escape XML special characters
|
|
420
|
+
*/
|
|
421
|
+
function escapeXml(unsafe) {
|
|
422
|
+
return unsafe.replace(/[<>&'"]/g, (c) => {
|
|
423
|
+
switch (c) {
|
|
424
|
+
case "<":
|
|
425
|
+
return "<";
|
|
426
|
+
case ">":
|
|
427
|
+
return ">";
|
|
428
|
+
case "&":
|
|
429
|
+
return "&";
|
|
430
|
+
case "'":
|
|
431
|
+
return "'";
|
|
432
|
+
case '"':
|
|
433
|
+
return """;
|
|
434
|
+
/* istanbul ignore next */
|
|
435
|
+
default:
|
|
436
|
+
return c;
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
export { TEST_PLAN_TOOLS, configureTestPlanTools };
|