@azure-devops/mcp 1.2.1 → 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,125 @@
1
+ // Copyright (c) Microsoft Corporation.
2
+ // Licensed under the MIT License.
3
+ import { describe, expect, it } from '@jest/globals';
4
+ import { configureTestPlanTools } from '../../../src/tools/testplans';
5
+ describe("configureTestPlanTools", () => {
6
+ let server;
7
+ let tokenProvider;
8
+ let connectionProvider;
9
+ let mockConnection;
10
+ let mockTestPlanApi;
11
+ let mockTestResultsApi;
12
+ beforeEach(() => {
13
+ server = { tool: jest.fn() };
14
+ tokenProvider = jest.fn();
15
+ mockTestPlanApi = {
16
+ getTestPlans: jest.fn(),
17
+ createTestPlan: jest.fn(),
18
+ addTestCasesToSuite: jest.fn(),
19
+ getTestCaseList: jest.fn(),
20
+ };
21
+ mockTestResultsApi = {
22
+ getTestResultDetailsForBuild: jest.fn(),
23
+ };
24
+ mockConnection = {
25
+ getTestPlanApi: jest.fn().mockResolvedValue(mockTestPlanApi),
26
+ getTestResultsApi: jest.fn().mockResolvedValue(mockTestResultsApi),
27
+ };
28
+ connectionProvider = jest.fn().mockResolvedValue(mockConnection);
29
+ });
30
+ describe("tool registration", () => {
31
+ it("registers test plan tools on the server", () => {
32
+ configureTestPlanTools(server, tokenProvider, connectionProvider);
33
+ expect(server.tool.mock.calls.map(call => call[0])).toEqual(expect.arrayContaining([
34
+ "ado_list_test_plans",
35
+ "ado_create_test_plan",
36
+ "ado_add_test_cases_to_suite",
37
+ "ado_list_test_cases",
38
+ "ado_show_test_results_from_build_id",
39
+ ]));
40
+ });
41
+ });
42
+ describe("list_test_plans tool", () => {
43
+ it("should call getTestPlans with the correct parameters and return the expected result", async () => {
44
+ configureTestPlanTools(server, tokenProvider, connectionProvider);
45
+ const call = server.tool.mock.calls.find(([toolName]) => toolName === "ado_list_test_plans");
46
+ if (!call)
47
+ throw new Error("ado_list_test_plans tool not registered");
48
+ const [, , , handler] = call;
49
+ mockTestPlanApi.getTestPlans.mockResolvedValue([{ id: 1, name: "Test Plan 1" }]);
50
+ const params = {
51
+ project: "proj1",
52
+ filterActivePlans: true,
53
+ includePlanDetails: false,
54
+ continuationToken: undefined,
55
+ };
56
+ const result = await handler(params);
57
+ expect(mockTestPlanApi.getTestPlans).toHaveBeenCalledWith("proj1", "", undefined, false, true);
58
+ expect(result.content[0].text).toBe(JSON.stringify([{ id: 1, name: "Test Plan 1" }], null, 2));
59
+ });
60
+ });
61
+ describe("create_test_plan tool", () => {
62
+ it("should call createTestPlan with the correct parameters and return the expected result", async () => {
63
+ configureTestPlanTools(server, tokenProvider, connectionProvider);
64
+ const call = server.tool.mock.calls.find(([toolName]) => toolName === "ado_create_test_plan");
65
+ if (!call)
66
+ throw new Error("ado_create_test_plan tool not registered");
67
+ const [, , , handler] = call;
68
+ mockTestPlanApi.createTestPlan.mockResolvedValue({ id: 1, name: "New Test Plan" });
69
+ const params = {
70
+ project: "proj1",
71
+ name: "New Test Plan",
72
+ iteration: "Iteration 1",
73
+ description: "Description",
74
+ startDate: "2025-05-01",
75
+ endDate: "2025-05-31",
76
+ areaPath: "Area 1",
77
+ };
78
+ const result = await handler(params);
79
+ expect(mockTestPlanApi.createTestPlan).toHaveBeenCalledWith({
80
+ name: "New Test Plan",
81
+ iteration: "Iteration 1",
82
+ description: "Description",
83
+ startDate: new Date("2025-05-01"),
84
+ endDate: new Date("2025-05-31"),
85
+ areaPath: "Area 1",
86
+ }, "proj1");
87
+ expect(result.content[0].text).toBe(JSON.stringify({ id: 1, name: "New Test Plan" }, null, 2));
88
+ });
89
+ });
90
+ describe("list_test_cases tool", () => {
91
+ it("should call getTestCaseList with the correct parameters and return the expected result", async () => {
92
+ configureTestPlanTools(server, tokenProvider, connectionProvider);
93
+ const call = server.tool.mock.calls.find(([toolName]) => toolName === "ado_list_test_cases");
94
+ if (!call)
95
+ throw new Error("ado_list_test_cases tool not registered");
96
+ const [, , , handler] = call;
97
+ mockTestPlanApi.getTestCaseList.mockResolvedValue([{ id: 1, name: "Test Case 1" }]);
98
+ const params = {
99
+ project: "proj1",
100
+ planid: 1,
101
+ suiteid: 2,
102
+ };
103
+ const result = await handler(params);
104
+ expect(mockTestPlanApi.getTestCaseList).toHaveBeenCalledWith("proj1", 1, 2);
105
+ expect(result.content[0].text).toBe(JSON.stringify([{ id: 1, name: "Test Case 1" }], null, 2));
106
+ });
107
+ });
108
+ describe("test_results_from_build_id tool", () => {
109
+ it("should call getTestResultDetailsForBuild with the correct parameters and return the expected result", async () => {
110
+ configureTestPlanTools(server, tokenProvider, connectionProvider);
111
+ const call = server.tool.mock.calls.find(([toolName]) => toolName === "ado_show_test_results_from_build_id");
112
+ if (!call)
113
+ throw new Error("ado_show_test_results_from_build_id tool not registered");
114
+ const [, , , handler] = call;
115
+ mockTestResultsApi.getTestResultDetailsForBuild.mockResolvedValue({ results: ["Result 1"] });
116
+ const params = {
117
+ project: "proj1",
118
+ buildid: 123,
119
+ };
120
+ const result = await handler(params);
121
+ expect(mockTestResultsApi.getTestResultDetailsForBuild).toHaveBeenCalledWith("proj1", 123);
122
+ expect(result.content[0].text).toBe(JSON.stringify({ results: ["Result 1"] }, null, 2));
123
+ });
124
+ });
125
+ });
@@ -79,7 +79,7 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider) {
79
79
  server.tool(Test_Plan_Tools.create_test_case, "Creates a new test case work item.", {
80
80
  project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
81
81
  title: z.string().describe("The title of the test case."),
82
- steps: z.string().optional().describe("The steps to reproduce the test case. Make sure to format each step as '1. Step one\\n2. Step two' etc."),
82
+ steps: z.string().optional().describe("The steps to reproduce the test case. Make sure to format each step as '1. Step one|Expected result one\n2. Step two|Expected result two"),
83
83
  priority: z.number().optional().describe("The priority of the test case."),
84
84
  areaPath: z.string().optional().describe("The area path for the test case."),
85
85
  iterationPath: z.string().optional().describe("The iteration path for the test case."),
@@ -165,17 +165,21 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider) {
165
165
  * Helper function to convert steps text to XML format required
166
166
  */
167
167
  function convertStepsToXml(steps) {
168
+ // Accepts steps in the format: '1. Step one|Expected result one\n2. Step two|Expected result two'
168
169
  const stepsLines = steps.split("\n").filter((line) => line.trim() !== "");
169
170
  let xmlSteps = `<steps id="0" last="${stepsLines.length}">`;
170
171
  for (let i = 0; i < stepsLines.length; i++) {
171
172
  const stepLine = stepsLines[i].trim();
172
173
  if (stepLine) {
173
- const stepMatch = stepLine.match(/^(\d+)\.\s*(.+)$/);
174
- const stepText = stepMatch ? stepMatch[2] : stepLine;
174
+ // Split step and expected result by '|', fallback to default if not provided
175
+ const [stepPart, expectedPart] = stepLine.split("|").map((s) => s.trim());
176
+ const stepMatch = stepPart.match(/^(\d+)\.\s*(.+)$/);
177
+ const stepText = stepMatch ? stepMatch[2] : stepPart;
178
+ const expectedText = expectedPart || "Verify step completes successfully";
175
179
  xmlSteps += `
176
180
  <step id="${i + 1}" type="ActionStep">
177
181
  <parameterizedString isformatted="true">${escapeXml(stepText)}</parameterizedString>
178
- <parameterizedString isformatted="true">Verify step completes successfully</parameterizedString>
182
+ <parameterizedString isformatted="true">${escapeXml(expectedText)}</parameterizedString>
179
183
  </step>`;
180
184
  }
181
185
  }
@@ -0,0 +1,6 @@
1
+ // Copyright (c) Microsoft Corporation.
2
+ // Licensed under the MIT License.
3
+ import { packageVersion } from "./version.js";
4
+ export const apiVersion = "7.2-preview.1";
5
+ export const batchApiVersion = "5.0";
6
+ export const userAgent = `AzureDevOps.MCP/${packageVersion} (local)`;
@@ -0,0 +1,87 @@
1
+ // Copyright (c) Microsoft Corporation.
2
+ // Licensed under the MIT License.
3
+ import { describe, expect, it } from '@jest/globals';
4
+ import { configureWikiTools } from '../../../src/tools/wiki';
5
+ describe("configureWikiTools", () => {
6
+ let server;
7
+ let tokenProvider;
8
+ let connectionProvider;
9
+ let mockConnection;
10
+ let mockWikiApi;
11
+ beforeEach(() => {
12
+ server = { tool: jest.fn() };
13
+ tokenProvider = jest.fn();
14
+ mockWikiApi = {
15
+ getWiki: jest.fn(),
16
+ getAllWikis: jest.fn(),
17
+ getPagesBatch: jest.fn(),
18
+ getPageText: jest.fn(),
19
+ };
20
+ mockConnection = {
21
+ getWikiApi: jest.fn().mockResolvedValue(mockWikiApi),
22
+ };
23
+ connectionProvider = jest.fn().mockResolvedValue(mockConnection);
24
+ });
25
+ describe("tool registration", () => {
26
+ it("registers wiki tools on the server", () => {
27
+ configureWikiTools(server, tokenProvider, connectionProvider);
28
+ expect(server.tool).toHaveBeenCalled();
29
+ });
30
+ });
31
+ describe("get_wiki_page_content tool", () => {
32
+ it("should call getPageText with the correct parameters and return the expected result", async () => {
33
+ configureWikiTools(server, tokenProvider, connectionProvider);
34
+ const call = server.tool.mock.calls.find(([toolName]) => toolName === "ado_get_wiki_page_content");
35
+ if (!call)
36
+ throw new Error("ado_get_wiki_page_content tool not registered");
37
+ const [, , , handler] = call;
38
+ // Mock a stream-like object for getPageText
39
+ const mockStream = {
40
+ setEncoding: jest.fn(),
41
+ on: function (event, cb) {
42
+ if (event === "data") {
43
+ setImmediate(() => cb("mock page text"));
44
+ }
45
+ if (event === "end") {
46
+ setImmediate(() => cb());
47
+ }
48
+ return this;
49
+ }
50
+ };
51
+ mockWikiApi.getPageText.mockResolvedValue(mockStream);
52
+ const params = {
53
+ wikiIdentifier: "wiki1",
54
+ project: "proj1",
55
+ path: "/page1"
56
+ };
57
+ const result = await handler(params);
58
+ expect(mockWikiApi.getPageText).toHaveBeenCalledWith("proj1", "wiki1", "/page1", undefined, undefined, true);
59
+ expect(result.content[0].text).toBe("\"mock page text\"");
60
+ });
61
+ });
62
+ describe("list_wiki_pages tool", () => {
63
+ it("should call getPagesBatch with the correct parameters and return the expected result", async () => {
64
+ configureWikiTools(server, tokenProvider, connectionProvider);
65
+ const call = server.tool.mock.calls.find(([toolName]) => toolName === "ado_list_wiki_pages");
66
+ if (!call)
67
+ throw new Error("ado_list_wiki_pages tool not registered");
68
+ const [, , , handler] = call;
69
+ mockWikiApi.getPagesBatch.mockResolvedValue({ value: ["page1", "page2"] });
70
+ const params = {
71
+ wikiIdentifier: "wiki2",
72
+ project: "proj2",
73
+ top: 10,
74
+ continuationToken: "token123",
75
+ pageViewsForDays: 7
76
+ };
77
+ const result = await handler(params);
78
+ const parsedResult = JSON.parse(result.content[0].text);
79
+ expect(mockWikiApi.getPagesBatch).toHaveBeenCalledWith({
80
+ top: 10,
81
+ continuationToken: "token123",
82
+ pageViewsForDays: 7
83
+ }, "proj2", "wiki2");
84
+ expect(parsedResult.value).toEqual(["page1", "page2"]);
85
+ });
86
+ });
87
+ });
@@ -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,5 +1,6 @@
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";
@@ -21,6 +22,7 @@ const WORKITEM_TOOLS = {
21
22
  get_query_results_by_id: "wit_get_query_results_by_id",
22
23
  update_work_items_batch: "wit_update_work_items_batch",
23
24
  work_items_link: "wit_work_items_link",
25
+ work_item_unlink: "wit_work_item_unlink",
24
26
  };
25
27
  function getLinkTypeFromName(name) {
26
28
  switch (name.toLowerCase()) {
@@ -46,6 +48,8 @@ function getLinkTypeFromName(name) {
46
48
  return "Microsoft.VSTS.Common.Affects-Forward";
47
49
  case "affected by":
48
50
  return "Microsoft.VSTS.Common.Affects-Reverse";
51
+ case "artifact":
52
+ return "ArtifactLink";
49
53
  default:
50
54
  throw new Error(`Unknown link type: ${name}`);
51
55
  }
@@ -95,8 +99,20 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
95
99
  }, async ({ project, ids }) => {
96
100
  const connection = await connectionProvider();
97
101
  const workItemApi = await connection.getWorkItemTrackingApi();
98
- 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"];
99
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
+ }
100
116
  return {
101
117
  content: [{ type: "text", text: JSON.stringify(workitems, null, 2) }],
102
118
  };
@@ -279,13 +295,15 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
279
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."),
280
296
  pullRequestId: z.number().describe("The ID of the pull request to link to."),
281
297
  workItemId: z.number().describe("The ID of the work item to link to the pull request."),
282
- }, 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 }) => {
283
300
  try {
284
301
  const connection = await connectionProvider();
285
302
  const workItemTrackingApi = await connection.getWorkItemTrackingApi();
286
303
  // Create artifact link relation using vstfs format
287
304
  // Format: vstfs:///Git/PullRequestId/{project}/{repositoryId}/{pullRequestId}
288
- const artifactPathValue = `${projectId}/${repositoryId}/${pullRequestId}`;
305
+ const artifactProjectId = pullRequestProjectId && pullRequestProjectId.trim() !== "" ? pullRequestProjectId : projectId;
306
+ const artifactPathValue = `${artifactProjectId}/${repositoryId}/${pullRequestId}`;
289
307
  const vstfsUrl = `vstfs:///Git/PullRequestId/${encodeURIComponent(artifactPathValue)}`;
290
308
  // Use the PATCH document format for adding a relation
291
309
  const patchDocument = [
@@ -573,5 +591,69 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
573
591
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
574
592
  };
575
593
  });
594
+ server.tool(WORKITEM_TOOLS.work_item_unlink, "Remove one or many links from a single work item", {
595
+ project: z.string().describe("The name or ID of the Azure DevOps project."),
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),
641
+ },
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
+ };
656
+ }
657
+ });
576
658
  }
577
659
  export { WORKITEM_TOOLS, configureWorkItemTools };