@azure-devops/mcp 1.2.0 → 1.3.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.
- package/LICENSE.md +21 -21
- package/README.md +30 -145
- package/dist/shared/tool-validation.js +92 -0
- package/dist/tools/advsec.js +108 -0
- package/dist/tools/core.test.js +1 -0
- package/dist/tools/repos.js +181 -19
- package/dist/tools/search.js +15 -15
- package/dist/tools/testplan.test.js +125 -0
- package/dist/tools/testplans.js +8 -4
- package/dist/tools/utils.js +6 -0
- package/dist/tools/wiki.test.js +87 -0
- package/dist/tools/workitem.test.js +101 -0
- package/dist/tools/workitems.js +88 -57
- package/dist/tools/workitems.test.js +530 -0
- package/dist/tools.js +8 -6
- package/dist/utils.js +26 -0
- package/dist/version.js +1 -1
- package/package.json +4 -2
- package/dist/http.js +0 -52
- package/dist/server.js +0 -36
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { configureWorkItemTools } from "../../../src/tools/workitems";
|
|
3
|
+
describe("configureWorkItemTools", () => {
|
|
4
|
+
let server;
|
|
5
|
+
let tokenProvider;
|
|
6
|
+
let connectionProvider;
|
|
7
|
+
let mockConnection;
|
|
8
|
+
let mockWorkApi;
|
|
9
|
+
let mockWorkItemTrackingApi;
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
server = { tool: jest.fn() };
|
|
12
|
+
tokenProvider = jest.fn();
|
|
13
|
+
mockWorkApi = {
|
|
14
|
+
getBacklogs: jest.fn(),
|
|
15
|
+
getPredefinedQueryResults: jest.fn(),
|
|
16
|
+
getTeamIterations: jest.fn(),
|
|
17
|
+
getIterationWorkItems: jest.fn(),
|
|
18
|
+
};
|
|
19
|
+
mockWorkItemTrackingApi = {
|
|
20
|
+
getWorkItemsBatch: jest.fn(),
|
|
21
|
+
getWorkItem: jest.fn(),
|
|
22
|
+
getComments: jest.fn(),
|
|
23
|
+
addComment: jest.fn(),
|
|
24
|
+
updateWorkItem: jest.fn(),
|
|
25
|
+
createWorkItem: jest.fn(),
|
|
26
|
+
getWorkItemType: jest.fn(),
|
|
27
|
+
getQuery: jest.fn(),
|
|
28
|
+
queryById: jest.fn(),
|
|
29
|
+
};
|
|
30
|
+
mockConnection = {
|
|
31
|
+
getWorkApi: jest.fn().mockResolvedValue(mockWorkApi),
|
|
32
|
+
getWorkItemTrackingApi: jest.fn().mockResolvedValue(mockWorkItemTrackingApi),
|
|
33
|
+
};
|
|
34
|
+
connectionProvider = jest.fn().mockResolvedValue(mockConnection);
|
|
35
|
+
});
|
|
36
|
+
describe("tool registration", () => {
|
|
37
|
+
it("registers core tools on the server", () => {
|
|
38
|
+
configureWorkItemTools(server, tokenProvider, connectionProvider);
|
|
39
|
+
expect(server.tool).toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe("list_backlogs tool", () => {
|
|
43
|
+
it("should call getBacklogs API with the correct parameters and return the expected result", async () => {
|
|
44
|
+
configureWorkItemTools(server, tokenProvider, connectionProvider);
|
|
45
|
+
const call = server.tool.mock.calls.find(([toolName]) => toolName === "ado_list_backlogs");
|
|
46
|
+
if (!call)
|
|
47
|
+
throw new Error("ado_list_backlogs tool not registered");
|
|
48
|
+
const [, , , handler] = call;
|
|
49
|
+
mockWorkApi.getBacklogs.mockResolvedValue([
|
|
50
|
+
{
|
|
51
|
+
id: "eb6e4656-77fc-42a1-9181-4c6d8e9da5d1",
|
|
52
|
+
name: "Fabrikam-Fiber-TFVC",
|
|
53
|
+
description: "Team Foundation Version Control projects.",
|
|
54
|
+
url: "https://dev.azure.com/fabrikam/_apis/projects/eb6e4656-77fc-42a1-9181-4c6d8e9da5d1",
|
|
55
|
+
state: "wellFormed",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "6ce954b1-ce1f-45d1-b94d-e6bf2464ba2c",
|
|
59
|
+
name: "Fabrikam-Fiber-Git",
|
|
60
|
+
description: "Git projects",
|
|
61
|
+
url: "https://dev.azure.com/fabrikam/_apis/projects/6ce954b1-ce1f-45d1-b94d-e6bf2464ba2c",
|
|
62
|
+
state: "wellFormed",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "281f9a5b-af0d-49b4-a1df-fe6f5e5f84d0",
|
|
66
|
+
name: "TestGit",
|
|
67
|
+
url: "https://dev.azure.com/fabrikam/_apis/projects/281f9a5b-af0d-49b4-a1df-fe6f5e5f84d0",
|
|
68
|
+
state: "wellFormed",
|
|
69
|
+
},
|
|
70
|
+
]);
|
|
71
|
+
const params = {
|
|
72
|
+
project: "Contoso",
|
|
73
|
+
team: "Fabrikam",
|
|
74
|
+
};
|
|
75
|
+
const result = await handler(params);
|
|
76
|
+
expect(mockWorkApi.getBacklogs).toHaveBeenCalledWith({ project: "Contoso", team: "Fabrikam" });
|
|
77
|
+
expect(result.content[0].text).toBe(JSON.stringify([
|
|
78
|
+
{
|
|
79
|
+
id: "eb6e4656-77fc-42a1-9181-4c6d8e9da5d1",
|
|
80
|
+
name: "Fabrikam-Fiber-TFVC",
|
|
81
|
+
description: "Team Foundation Version Control projects.",
|
|
82
|
+
url: "https://dev.azure.com/fabrikam/_apis/projects/eb6e4656-77fc-42a1-9181-4c6d8e9da5d1",
|
|
83
|
+
state: "wellFormed",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "6ce954b1-ce1f-45d1-b94d-e6bf2464ba2c",
|
|
87
|
+
name: "Fabrikam-Fiber-Git",
|
|
88
|
+
description: "Git projects",
|
|
89
|
+
url: "https://dev.azure.com/fabrikam/_apis/projects/6ce954b1-ce1f-45d1-b94d-e6bf2464ba2c",
|
|
90
|
+
state: "wellFormed",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "281f9a5b-af0d-49b4-a1df-fe6f5e5f84d0",
|
|
94
|
+
name: "TestGit",
|
|
95
|
+
url: "https://dev.azure.com/fabrikam/_apis/projects/281f9a5b-af0d-49b4-a1df-fe6f5e5f84d0",
|
|
96
|
+
state: "wellFormed",
|
|
97
|
+
},
|
|
98
|
+
], null, 2));
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
package/dist/tools/workitems.js
CHANGED
|
@@ -1,16 +1,9 @@
|
|
|
1
1
|
// Copyright (c) Microsoft Corporation.
|
|
2
2
|
// Licensed under the MIT License.
|
|
3
|
+
import { WorkItemExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
|
|
3
4
|
import { QueryExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { batchApiVersion, markdownCommentsApiVersion, getEnumKeys, safeEnumConvert } from "../utils.js";
|
|
6
|
-
/**
|
|
7
|
-
* Converts Operation enum key to lowercase string for API usage
|
|
8
|
-
* @param operation The Operation enum key (e.g., "Add", "Replace", "Remove")
|
|
9
|
-
* @returns Lowercase string for API usage (e.g., "add", "replace", "remove")
|
|
10
|
-
*/
|
|
11
|
-
function operationToApiString(operation) {
|
|
12
|
-
return operation.toLowerCase();
|
|
13
|
-
}
|
|
14
7
|
const WORKITEM_TOOLS = {
|
|
15
8
|
my_work_items: "wit_my_work_items",
|
|
16
9
|
list_backlogs: "wit_list_backlogs",
|
|
@@ -28,8 +21,8 @@ const WORKITEM_TOOLS = {
|
|
|
28
21
|
get_query: "wit_get_query",
|
|
29
22
|
get_query_results_by_id: "wit_get_query_results_by_id",
|
|
30
23
|
update_work_items_batch: "wit_update_work_items_batch",
|
|
31
|
-
close_and_link_workitem_duplicates: "wit_close_and_link_workitem_duplicates",
|
|
32
24
|
work_items_link: "wit_work_items_link",
|
|
25
|
+
work_item_unlink: "wit_work_item_unlink",
|
|
33
26
|
};
|
|
34
27
|
function getLinkTypeFromName(name) {
|
|
35
28
|
switch (name.toLowerCase()) {
|
|
@@ -55,6 +48,8 @@ function getLinkTypeFromName(name) {
|
|
|
55
48
|
return "Microsoft.VSTS.Common.Affects-Forward";
|
|
56
49
|
case "affected by":
|
|
57
50
|
return "Microsoft.VSTS.Common.Affects-Reverse";
|
|
51
|
+
case "artifact":
|
|
52
|
+
return "ArtifactLink";
|
|
58
53
|
default:
|
|
59
54
|
throw new Error(`Unknown link type: ${name}`);
|
|
60
55
|
}
|
|
@@ -104,8 +99,20 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
|
|
|
104
99
|
}, async ({ project, ids }) => {
|
|
105
100
|
const connection = await connectionProvider();
|
|
106
101
|
const workItemApi = await connection.getWorkItemTrackingApi();
|
|
107
|
-
const fields = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.Parent", "System.Tags"];
|
|
102
|
+
const fields = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.Parent", "System.Tags", "Microsoft.VSTS.Common.StackRank", "System.AssignedTo"];
|
|
108
103
|
const workitems = await workItemApi.getWorkItemsBatch({ ids, fields }, project);
|
|
104
|
+
// Format the assignedTo field to include displayName and uniqueName
|
|
105
|
+
// Removing the identity object as the response. It's too much and not needed
|
|
106
|
+
if (workitems && Array.isArray(workitems)) {
|
|
107
|
+
workitems.forEach((item) => {
|
|
108
|
+
if (item.fields && item.fields["System.AssignedTo"] && typeof item.fields["System.AssignedTo"] === "object") {
|
|
109
|
+
const assignedTo = item.fields["System.AssignedTo"];
|
|
110
|
+
const name = assignedTo.displayName || "";
|
|
111
|
+
const email = assignedTo.uniqueName || "";
|
|
112
|
+
item.fields["System.AssignedTo"] = `${name} <${email}>`.trim();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
109
116
|
return {
|
|
110
117
|
content: [{ type: "text", text: JSON.stringify(workitems, null, 2) }],
|
|
111
118
|
};
|
|
@@ -288,13 +295,15 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
|
|
|
288
295
|
repositoryId: z.string().describe("The ID of the repository containing the pull request. Do not use the repository name here, use the ID instead."),
|
|
289
296
|
pullRequestId: z.number().describe("The ID of the pull request to link to."),
|
|
290
297
|
workItemId: z.number().describe("The ID of the work item to link to the pull request."),
|
|
291
|
-
|
|
298
|
+
pullRequestProjectId: z.string().optional().describe("The project ID containing the pull request. If not provided, defaults to the work item's project ID (for same-project linking)."),
|
|
299
|
+
}, async ({ projectId, repositoryId, pullRequestId, workItemId, pullRequestProjectId }) => {
|
|
292
300
|
try {
|
|
293
301
|
const connection = await connectionProvider();
|
|
294
302
|
const workItemTrackingApi = await connection.getWorkItemTrackingApi();
|
|
295
303
|
// Create artifact link relation using vstfs format
|
|
296
304
|
// Format: vstfs:///Git/PullRequestId/{project}/{repositoryId}/{pullRequestId}
|
|
297
|
-
const
|
|
305
|
+
const artifactProjectId = pullRequestProjectId && pullRequestProjectId.trim() !== "" ? pullRequestProjectId : projectId;
|
|
306
|
+
const artifactPathValue = `${artifactProjectId}/${repositoryId}/${pullRequestId}`;
|
|
298
307
|
const vstfsUrl = `vstfs:///Git/PullRequestId/${encodeURIComponent(artifactPathValue)}`;
|
|
299
308
|
// Use the PATCH document format for adding a relation
|
|
300
309
|
const patchDocument = [
|
|
@@ -353,7 +362,12 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
|
|
|
353
362
|
id: z.number().describe("The ID of the work item to update."),
|
|
354
363
|
updates: z
|
|
355
364
|
.array(z.object({
|
|
356
|
-
op: z
|
|
365
|
+
op: z
|
|
366
|
+
.string()
|
|
367
|
+
.transform((val) => val.toLowerCase())
|
|
368
|
+
.pipe(z.enum(["add", "replace", "remove"]))
|
|
369
|
+
.default("add")
|
|
370
|
+
.describe("The operation to perform on the field."),
|
|
357
371
|
path: z.string().describe("The path of the field to update, e.g., '/fields/System.Title'."),
|
|
358
372
|
value: z.string().describe("The new value for the field. This is required for 'Add' and 'Replace' operations, and should be omitted for 'Remove' operations."),
|
|
359
373
|
}))
|
|
@@ -364,7 +378,7 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
|
|
|
364
378
|
// Convert operation names to lowercase for API
|
|
365
379
|
const apiUpdates = updates.map((update) => ({
|
|
366
380
|
...update,
|
|
367
|
-
op:
|
|
381
|
+
op: update.op,
|
|
368
382
|
}));
|
|
369
383
|
const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id);
|
|
370
384
|
return {
|
|
@@ -577,52 +591,69 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
|
|
|
577
591
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
578
592
|
};
|
|
579
593
|
});
|
|
580
|
-
server.tool(WORKITEM_TOOLS.
|
|
581
|
-
id: z.number().describe("The ID of the work item to close and link duplicates to."),
|
|
582
|
-
duplicateIds: z.array(z.number()).describe("An array of IDs of the duplicate work items to close and link to the specified work item."),
|
|
594
|
+
server.tool(WORKITEM_TOOLS.work_item_unlink, "Remove one or many links from a single work item", {
|
|
583
595
|
project: z.string().describe("The name or ID of the Azure DevOps project."),
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
596
|
+
id: z.number().describe("The ID of the work item to remove the links from."),
|
|
597
|
+
type: z
|
|
598
|
+
.enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by", "artifact"])
|
|
599
|
+
.default("related")
|
|
600
|
+
.describe("Type of link to remove. Options include 'parent', 'child', 'duplicate', 'duplicate of', 'related', 'successor', 'predecessor', 'tested by', 'tests', 'affects', 'affected by', and 'artifact'. Defaults to 'related'."),
|
|
601
|
+
url: z.string().optional().describe("Optional URL to match for the link to remove. If not provided, all links of the specified type will be removed."),
|
|
602
|
+
}, async ({ project, id, type, url }) => {
|
|
603
|
+
try {
|
|
604
|
+
const connection = await connectionProvider();
|
|
605
|
+
const workItemApi = await connection.getWorkItemTrackingApi();
|
|
606
|
+
const workItem = await workItemApi.getWorkItem(id, undefined, undefined, WorkItemExpand.Relations, project);
|
|
607
|
+
const relations = workItem.relations ?? [];
|
|
608
|
+
const linkType = getLinkTypeFromName(type);
|
|
609
|
+
let relationIndexes = [];
|
|
610
|
+
if (url && url.trim().length > 0) {
|
|
611
|
+
// If url is provided, find relations matching both rel type and url
|
|
612
|
+
relationIndexes = relations.map((relation, idx) => (relation.url === url ? idx : -1)).filter((idx) => idx !== -1);
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
// If url is not provided, find all relations matching rel type
|
|
616
|
+
relationIndexes = relations.map((relation, idx) => (relation.rel === linkType ? idx : -1)).filter((idx) => idx !== -1);
|
|
617
|
+
}
|
|
618
|
+
if (relationIndexes.length === 0) {
|
|
619
|
+
return {
|
|
620
|
+
content: [{ type: "text", text: `No matching relations found for link type '${type}'${url ? ` and URL '${url}'` : ""}.\n${JSON.stringify(relations, null, 2)}` }],
|
|
621
|
+
isError: true,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
// Get the relations that will be removed for logging
|
|
625
|
+
const removedRelations = relationIndexes.map((idx) => relations[idx]);
|
|
626
|
+
// Sort indexes in descending order to avoid index shifting when removing
|
|
627
|
+
relationIndexes.sort((a, b) => b - a);
|
|
628
|
+
const apiUpdates = relationIndexes.map((idx) => ({
|
|
629
|
+
op: "remove",
|
|
630
|
+
path: `/relations/${idx}`,
|
|
631
|
+
}));
|
|
632
|
+
const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id, project);
|
|
633
|
+
return {
|
|
634
|
+
content: [
|
|
635
|
+
{
|
|
636
|
+
type: "text",
|
|
637
|
+
text: `Removed ${removedRelations.length} link(s) of type '${type}':\n` +
|
|
638
|
+
JSON.stringify(removedRelations, null, 2) +
|
|
639
|
+
`\n\nUpdated work item result:\n` +
|
|
640
|
+
JSON.stringify(updatedWorkItem, null, 2),
|
|
605
641
|
},
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
throw new Error(`Failed to update work items in batch: ${response.statusText}`);
|
|
642
|
+
],
|
|
643
|
+
isError: false,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
return {
|
|
648
|
+
content: [
|
|
649
|
+
{
|
|
650
|
+
type: "text",
|
|
651
|
+
text: `Error unlinking work item: ${error instanceof Error ? error.message : "Unknown error occurred"}`,
|
|
652
|
+
},
|
|
653
|
+
],
|
|
654
|
+
isError: true,
|
|
655
|
+
};
|
|
621
656
|
}
|
|
622
|
-
const result = await response.json();
|
|
623
|
-
return {
|
|
624
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
625
|
-
};
|
|
626
657
|
});
|
|
627
658
|
}
|
|
628
659
|
export { WORKITEM_TOOLS, configureWorkItemTools };
|