@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.
@@ -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
+ });
@@ -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
- }, async ({ projectId, repositoryId, pullRequestId, workItemId }) => {
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 artifactPathValue = `${projectId}/${repositoryId}/${pullRequestId}`;
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.enum(["Add", "Replace", "Remove"]).default("Add").describe("The operation to perform on the field."),
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: operationToApiString(update.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.close_and_link_workitem_duplicates, "Close duplicate work items by id.", {
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
- state: z.string().default("Removed").describe("The state to set for the duplicate work items. Defaults to 'Removed'."),
585
- }, async ({ id, duplicateIds, project, state }) => {
586
- const connection = await connectionProvider();
587
- const body = duplicateIds.map((duplicateId) => ({
588
- method: "PATCH",
589
- uri: `/_apis/wit/workitems/${duplicateId}?api-version=${batchApiVersion}`,
590
- headers: {
591
- "Content-Type": "application/json-patch+json",
592
- },
593
- body: [
594
- {
595
- op: "add",
596
- path: "/fields/System.State",
597
- value: `${state}`,
598
- },
599
- {
600
- op: "add",
601
- path: "/relations/-",
602
- value: {
603
- rel: "System.LinkTypes.Duplicate-Reverse",
604
- url: `${connection.serverUrl}/${project}/_apis/wit/workItems/${id}`,
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
- const accessToken = await tokenProvider();
610
- const response = await fetch(`${connection.serverUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, {
611
- method: "PATCH",
612
- headers: {
613
- "Authorization": `Bearer ${accessToken.token}`,
614
- "Content-Type": "application/json",
615
- "User-Agent": userAgentProvider(),
616
- },
617
- body: JSON.stringify(body),
618
- });
619
- if (!response.ok) {
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 };