@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.
@@ -0,0 +1,1130 @@
1
+ // Copyright (c) Microsoft Corporation.
2
+ // Licensed under the MIT License.
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import { WorkItemExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
6
+ import { QueryExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
7
+ import { z } from "zod";
8
+ import { batchApiVersion, markdownCommentsApiVersion, getEnumKeys, safeEnumConvert, encodeFormattedValue } from "../utils.js";
9
+ import { elicitProject, elicitTeam } from "../shared/elicitations.js";
10
+ import { createExternalContentResponse } from "../shared/content-safety.js";
11
+ import { getUserIdentityFromEmail } from "./auth.js";
12
+ const WORKITEM_TOOLS = {
13
+ wit_work_item: "wit_work_item",
14
+ wit_query: "wit_query",
15
+ wit_backlog: "wit_backlog",
16
+ wit_work_item_attachment: "wit_work_item_attachment",
17
+ wit_work_item_write: "wit_work_item_write",
18
+ wit_work_item_comment_write: "wit_work_item_comment_write",
19
+ wit_work_item_link_write: "wit_work_item_link_write",
20
+ };
21
+ function getLinkTypeFromName(name) {
22
+ switch (name.toLowerCase()) {
23
+ case "parent":
24
+ return "System.LinkTypes.Hierarchy-Reverse";
25
+ case "child":
26
+ return "System.LinkTypes.Hierarchy-Forward";
27
+ case "duplicate":
28
+ return "System.LinkTypes.Duplicate-Forward";
29
+ case "duplicate of":
30
+ return "System.LinkTypes.Duplicate-Reverse";
31
+ case "related":
32
+ return "System.LinkTypes.Related";
33
+ case "successor":
34
+ return "System.LinkTypes.Dependency-Forward";
35
+ case "predecessor":
36
+ return "System.LinkTypes.Dependency-Reverse";
37
+ case "tested by":
38
+ return "Microsoft.VSTS.Common.TestedBy-Forward";
39
+ case "tests":
40
+ return "Microsoft.VSTS.Common.TestedBy-Reverse";
41
+ case "affects":
42
+ return "Microsoft.VSTS.Common.Affects-Forward";
43
+ case "affected by":
44
+ return "Microsoft.VSTS.Common.Affects-Reverse";
45
+ case "artifact":
46
+ return "ArtifactLink";
47
+ case "hyperlink":
48
+ return "Hyperlink";
49
+ default:
50
+ throw new Error(`Unknown link type: ${name}`);
51
+ }
52
+ }
53
+ function getArtifactLinkAttributeName(linkType) {
54
+ switch (linkType) {
55
+ case "Wiki":
56
+ return "Wiki Page";
57
+ default:
58
+ return linkType;
59
+ }
60
+ }
61
+ function escapeHtml(value) {
62
+ const entities = {
63
+ "&": "&",
64
+ "<": "&lt;",
65
+ ">": "&gt;",
66
+ '"': "&quot;",
67
+ "'": "&#39;",
68
+ };
69
+ return value.replace(/[&<>"']/g, (character) => entities[character]);
70
+ }
71
+ async function resolveCommentMentions(text, format, tokenProvider, connectionProvider, userAgentProvider) {
72
+ const emailMatches = [...text.matchAll(/@<([^<>\s]+@[^<>\s]+)>/g)];
73
+ if (emailMatches.length === 0)
74
+ return text;
75
+ const identities = new Map();
76
+ for (const email of new Set(emailMatches.map((match) => match[1]))) {
77
+ try {
78
+ identities.set(email, await getUserIdentityFromEmail(email, tokenProvider, connectionProvider, userAgentProvider));
79
+ }
80
+ catch {
81
+ // Leave mentions unchanged when their identities cannot be resolved.
82
+ }
83
+ }
84
+ return text.replace(/@<([^<>\s]+@[^<>\s]+)>/g, (mention, email) => {
85
+ const identity = identities.get(email);
86
+ if (!identity)
87
+ return escapeHtml(mention);
88
+ return format === "Markdown" || format === undefined ? `@<${identity.id}>` : `<a href="#" data-vss-mention="version:2.0,${identity.id}">@${escapeHtml(identity.displayName)}</a>`;
89
+ });
90
+ }
91
+ function configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider) {
92
+ // --- wit_work_item ----------------------------------------------------------
93
+ server.tool(WORKITEM_TOOLS.wit_work_item, "Retrieve work item data for a project. Use the action parameter to specify the operation.", {
94
+ action: z
95
+ .enum(["get", "get_batch", "list_comments", "my", "list_revisions", "list_for_iteration", "get_type"])
96
+ .describe("The action to perform. Options: get (get a single work item by ID), get_batch (get multiple work items by IDs), list_comments (list comments on a work item), my (get work items relevant to the authenticated user), list_revisions (list revisions of a work item), list_for_iteration (list work items for a team iteration), get_type (get metadata for a work item type)."),
97
+ project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."),
98
+ id: z.coerce.number().min(1).optional().describe("Work item ID. Required for: get."),
99
+ ids: z.array(z.coerce.number().min(1)).optional().describe("Work item IDs. Required for: get_batch."),
100
+ workItemId: z.coerce.number().min(1).optional().describe("Work item ID. Required for: list_comments, list_revisions."),
101
+ fields: z.array(z.string()).optional().describe("Field names to include in the response. Used for: get, get_batch. For get, cannot be combined with expand."),
102
+ asOf: z.coerce.date().optional().describe("Retrieve the work item as of a specific date. Used for: get."),
103
+ expand: z
104
+ .enum(getEnumKeys(WorkItemExpand))
105
+ .optional()
106
+ .describe("Expand options (None, Fields, Relations, Links, All). Used for: get, list_revisions. For get, cannot be combined with fields."),
107
+ top: z.coerce.number().optional().describe("Maximum number of results to return. Used for: list_comments, my, list_revisions. Defaults vary by action."),
108
+ includeCompleted: z.boolean().optional().default(false).describe("Include completed work items. Used for: my. Defaults to false."),
109
+ type: z.enum(["assignedtome", "myactivity"]).optional().describe("Type of work items to retrieve. Used for: my. Defaults to 'assignedtome'."),
110
+ skip: z.coerce.number().optional().describe("Number of results to skip for pagination. Used for: list_revisions."),
111
+ team: z.string().optional().describe("Team name or ID. Used for: list_for_iteration."),
112
+ iterationId: z.string().optional().describe("Iteration ID. Required for: list_for_iteration."),
113
+ workItemType: z.string().optional().describe("Work item type name. Required for: get_type."),
114
+ }, async ({ action, project, id, ids, workItemId, fields, asOf, expand, top, includeCompleted, type, skip, team, iterationId, workItemType }) => {
115
+ try {
116
+ const connection = await connectionProvider();
117
+ let resolvedProject = project;
118
+ if (action === "get") {
119
+ if (!id)
120
+ return { content: [{ type: "text", text: "id is required for get" }], isError: true };
121
+ if (!resolvedProject) {
122
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve the work item from.");
123
+ if ("response" in result)
124
+ return result.response;
125
+ resolvedProject = result.resolved;
126
+ }
127
+ let effectiveExpand = expand;
128
+ if (fields && fields.length > 0 && effectiveExpand != null) {
129
+ effectiveExpand = "none";
130
+ }
131
+ const workItemApi = await connection.getWorkItemTrackingApi();
132
+ const workItem = await workItemApi.getWorkItem(id, fields, asOf, effectiveExpand, resolvedProject);
133
+ return { content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }] };
134
+ }
135
+ if (action === "get_batch") {
136
+ if (!ids || ids.length === 0)
137
+ return { content: [{ type: "text", text: "ids is required for get_batch" }], isError: true };
138
+ if (!resolvedProject) {
139
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve work items for.");
140
+ if ("response" in result)
141
+ return result.response;
142
+ resolvedProject = result.resolved;
143
+ }
144
+ const workItemApi = await connection.getWorkItemTrackingApi();
145
+ const defaultFields = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.Parent", "System.Tags", "Microsoft.VSTS.Common.StackRank", "System.AssignedTo"];
146
+ const fieldsToUse = !fields || fields.length === 0 ? defaultFields : fields;
147
+ const workitems = await workItemApi.getWorkItemsBatch({ ids, fields: fieldsToUse }, resolvedProject);
148
+ const identityFields = [
149
+ "System.AssignedTo",
150
+ "System.CreatedBy",
151
+ "System.ChangedBy",
152
+ "System.AuthorizedAs",
153
+ "Microsoft.VSTS.Common.ActivatedBy",
154
+ "Microsoft.VSTS.Common.ResolvedBy",
155
+ "Microsoft.VSTS.Common.ClosedBy",
156
+ ];
157
+ if (workitems && Array.isArray(workitems)) {
158
+ workitems.forEach((item) => {
159
+ if (item.fields) {
160
+ identityFields.forEach((fieldName) => {
161
+ if (item.fields && item.fields[fieldName] && typeof item.fields[fieldName] === "object") {
162
+ const identityField = item.fields[fieldName];
163
+ const name = identityField.displayName || "";
164
+ const email = identityField.uniqueName || "";
165
+ item.fields[fieldName] = `${name} <${email}>`.trim();
166
+ }
167
+ });
168
+ }
169
+ });
170
+ }
171
+ return { content: [{ type: "text", text: JSON.stringify(workitems, null, 2) }] };
172
+ }
173
+ if (action === "list_comments") {
174
+ if (!workItemId)
175
+ return { content: [{ type: "text", text: "workItemId is required for list_comments" }], isError: true };
176
+ if (!resolvedProject) {
177
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to list work item comments for.");
178
+ if ("response" in result)
179
+ return result.response;
180
+ resolvedProject = result.resolved;
181
+ }
182
+ const workItemApi = await connection.getWorkItemTrackingApi();
183
+ const comments = await workItemApi.getComments(resolvedProject, workItemId, top ?? 50);
184
+ return { content: [{ type: "text", text: JSON.stringify(comments, null, 2) }] };
185
+ }
186
+ if (action === "my") {
187
+ if (!resolvedProject) {
188
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve work items for.");
189
+ if ("response" in result)
190
+ return result.response;
191
+ resolvedProject = result.resolved;
192
+ }
193
+ const workApi = await connection.getWorkApi();
194
+ const workItems = await workApi.getPredefinedQueryResults(resolvedProject, type ?? "assignedtome", top ?? 50, includeCompleted ?? false);
195
+ return { content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }] };
196
+ }
197
+ if (action === "list_revisions") {
198
+ if (!workItemId)
199
+ return { content: [{ type: "text", text: "workItemId is required for list_revisions" }], isError: true };
200
+ if (!resolvedProject) {
201
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to list work item revisions for.");
202
+ if ("response" in result)
203
+ return result.response;
204
+ resolvedProject = result.resolved;
205
+ }
206
+ const workItemApi = await connection.getWorkItemTrackingApi();
207
+ const revisions = await workItemApi.getRevisions(workItemId, top ?? 50, skip, safeEnumConvert(WorkItemExpand, expand), resolvedProject);
208
+ if (revisions && Array.isArray(revisions)) {
209
+ revisions.forEach((revision) => {
210
+ if (revision.fields) {
211
+ const revFields = revision.fields;
212
+ Object.keys(revFields).forEach((fieldName) => {
213
+ const fieldValue = revFields[fieldName];
214
+ if (fieldValue &&
215
+ typeof fieldValue === "object" &&
216
+ !Array.isArray(fieldValue) &&
217
+ "displayName" in fieldValue &&
218
+ ("url" in fieldValue || "_links" in fieldValue || "uniqueName" in fieldValue)) {
219
+ delete fieldValue.url;
220
+ delete fieldValue._links;
221
+ delete fieldValue.id;
222
+ delete fieldValue.uniqueName;
223
+ delete fieldValue.imageUrl;
224
+ delete fieldValue.descriptor;
225
+ }
226
+ });
227
+ }
228
+ });
229
+ }
230
+ return { content: [{ type: "text", text: JSON.stringify(revisions, null, 2) }] };
231
+ }
232
+ if (action === "list_for_iteration") {
233
+ if (!iterationId)
234
+ return { content: [{ type: "text", text: "iterationId is required for list_for_iteration" }], isError: true };
235
+ if (!resolvedProject) {
236
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve work items for iteration.");
237
+ if ("response" in result)
238
+ return result.response;
239
+ resolvedProject = result.resolved;
240
+ }
241
+ const workApi = await connection.getWorkApi();
242
+ const workItems = await workApi.getIterationWorkItems({ project: resolvedProject, team }, iterationId);
243
+ return { content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }] };
244
+ }
245
+ if (action === "get_type") {
246
+ if (!workItemType)
247
+ return { content: [{ type: "text", text: "workItemType is required for get_type" }], isError: true };
248
+ if (!resolvedProject) {
249
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve the work item type from.");
250
+ if ("response" in result)
251
+ return result.response;
252
+ resolvedProject = result.resolved;
253
+ }
254
+ const workItemApi = await connection.getWorkItemTrackingApi();
255
+ const workItemTypeInfo = await workItemApi.getWorkItemType(resolvedProject, workItemType);
256
+ return { content: [{ type: "text", text: JSON.stringify(workItemTypeInfo, null, 2) }] };
257
+ }
258
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
259
+ }
260
+ catch (error) {
261
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
262
+ const msgs = {
263
+ get: `Error retrieving work item: ${errorMessage}`,
264
+ get_batch: `Error retrieving work items batch: ${errorMessage}`,
265
+ list_comments: `Error listing work item comments: ${errorMessage}`,
266
+ my: `Error retrieving work items: ${errorMessage}`,
267
+ list_revisions: `Error listing work item revisions: ${errorMessage}`,
268
+ list_for_iteration: `Error retrieving work items for iteration: ${errorMessage}`,
269
+ get_type: `Error retrieving work item type: ${errorMessage}`,
270
+ };
271
+ return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true };
272
+ }
273
+ });
274
+ // --- wit_query --------------------------------------------------------------
275
+ server.tool(WORKITEM_TOOLS.wit_query, "Retrieve work item query data for a project. Use the action parameter to specify the operation.", {
276
+ action: z
277
+ .enum(["get", "get_results", "wiql"])
278
+ .describe("The action to perform. Options: get (get a query by ID or path), get_results (run a saved query and return results), wiql (execute an ad-hoc WIQL query)."),
279
+ project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."),
280
+ query: z.string().optional().describe("The ID or path of the query. Required for: get."),
281
+ expand: z
282
+ .enum(getEnumKeys(QueryExpand))
283
+ .optional()
284
+ .describe("Expand parameter to include additional details. Used for: get."),
285
+ depth: z.coerce.number().default(0).describe("Depth of expansion. Used for: get. Defaults to 0."),
286
+ includeDeleted: z.boolean().default(false).describe("Include deleted items. Used for: get. Defaults to false."),
287
+ useIsoDateFormat: z.boolean().default(false).describe("Use ISO date format in the response. Used for: get. Defaults to false."),
288
+ id: z.string().optional().describe("The ID of the saved query. Required for: get_results."),
289
+ team: z.string().optional().describe("Team name or ID. Used for: get_results, wiql."),
290
+ timePrecision: z.boolean().optional().describe("Include time precision in date fields. Used for: get_results, wiql."),
291
+ top: z.coerce.number().default(50).describe("Maximum number of results to return. Used for: get_results, wiql. Defaults to 50."),
292
+ responseType: z.enum(["full", "ids"]).default("full").describe("Response type: 'full' returns complete results (default), 'ids' returns only work item IDs. Used for: get_results."),
293
+ wiql: z.string().max(32768).optional().describe('The WIQL query string to execute. Required for: wiql. Example: "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project".'),
294
+ }, async ({ action, project, query, expand, depth, includeDeleted, useIsoDateFormat, id, team, timePrecision, top, responseType, wiql }) => {
295
+ try {
296
+ const connection = await connectionProvider();
297
+ let resolvedProject = project;
298
+ if (!resolvedProject) {
299
+ const result = await elicitProject(server, connection, `Select the Azure DevOps project for ${action}.`);
300
+ if ("response" in result)
301
+ return result.response;
302
+ resolvedProject = result.resolved;
303
+ }
304
+ if (action === "get") {
305
+ if (!query)
306
+ return { content: [{ type: "text", text: "query is required for get" }], isError: true };
307
+ const workItemApi = await connection.getWorkItemTrackingApi();
308
+ const queryDetails = await workItemApi.getQuery(resolvedProject, query, safeEnumConvert(QueryExpand, expand), depth, includeDeleted, useIsoDateFormat);
309
+ return { content: [{ type: "text", text: JSON.stringify(queryDetails, null, 2) }] };
310
+ }
311
+ if (action === "get_results") {
312
+ if (!id)
313
+ return { content: [{ type: "text", text: "id is required for get_results" }], isError: true };
314
+ const workItemApi = await connection.getWorkItemTrackingApi();
315
+ const teamContext = { project: resolvedProject, team };
316
+ const queryResult = await workItemApi.queryById(id, teamContext, timePrecision, top);
317
+ if (responseType === "ids") {
318
+ const ids = queryResult.workItems?.map((workItem) => workItem.id).filter((wid) => wid !== undefined) || [];
319
+ return { content: [{ type: "text", text: JSON.stringify({ ids, count: ids.length }, null, 2) }] };
320
+ }
321
+ return { content: [{ type: "text", text: JSON.stringify(queryResult, null, 2) }] };
322
+ }
323
+ if (action === "wiql") {
324
+ if (!wiql)
325
+ return { content: [{ type: "text", text: "wiql is required for wiql" }], isError: true };
326
+ const workItemApi = await connection.getWorkItemTrackingApi();
327
+ const teamContext = { project: resolvedProject, team };
328
+ const queryResult = await workItemApi.queryByWiql({ query: wiql }, teamContext, timePrecision, top);
329
+ return createExternalContentResponse(queryResult, "wiql query results");
330
+ }
331
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
332
+ }
333
+ catch (error) {
334
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
335
+ const msgs = {
336
+ get: `Error retrieving query: ${errorMessage}`,
337
+ get_results: `Error retrieving query results: ${errorMessage}`,
338
+ wiql: `Error executing WIQL query: ${errorMessage}`,
339
+ };
340
+ return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true };
341
+ }
342
+ });
343
+ // --- wit_backlog ------------------------------------------------------------
344
+ server.tool(WORKITEM_TOOLS.wit_backlog, "Retrieve backlog data for a project and team. Use the action parameter to specify the operation.", {
345
+ action: z
346
+ .enum(["list", "list_work_items", "reorder"])
347
+ .describe("The action to perform. Options: list (list backlog levels for a team), list_work_items (list work items in a specific backlog level), reorder (move work items to a new position in a backlog or iteration)."),
348
+ project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."),
349
+ team: z.string().optional().describe("The name or ID of the Azure DevOps team. Reuse from prior context if already known. If not provided, a team selection prompt will be shown."),
350
+ backlogId: z.string().optional().describe("The ID of the backlog category to retrieve work items from. Required for: list_work_items."),
351
+ ids: z.array(z.coerce.number().int().min(1)).min(1).optional().describe("The IDs of the work items to reorder. Required for: reorder."),
352
+ previousId: z.coerce
353
+ .number()
354
+ .int()
355
+ .min(0)
356
+ .optional()
357
+ .describe("The ID of the work item that should be before the reordered items. Use 0 to specify the beginning of the list. Optional for: reorder."),
358
+ nextId: z.coerce.number().int().min(0).optional().describe("The ID of the work item that should be after the reordered items. Use 0 to specify the end of the list. Optional for: reorder."),
359
+ parentId: z.coerce.number().int().min(0).optional().describe("The parent ID for all work items involved in the operation. Use 0 to indicate the items have no parent. Optional for: reorder."),
360
+ iterationPath: z.string().optional().describe("The iteration path for the reorder operation. Used when reordering items in an iteration backlog. Optional for: reorder."),
361
+ iterationId: z.string().optional().describe("The iteration ID. When provided, reorder items in that iteration instead of the team backlog. Used for: reorder."),
362
+ }, async ({ action, project, team, backlogId, ids, previousId, nextId, parentId, iterationPath, iterationId }) => {
363
+ try {
364
+ const connection = await connectionProvider();
365
+ let resolvedProject = project;
366
+ if (!resolvedProject) {
367
+ const label = action === "list" ? "list backlogs" : "list backlog work items";
368
+ const result = await elicitProject(server, connection, `Select the Azure DevOps project to ${label} for.`);
369
+ if ("response" in result)
370
+ return result.response;
371
+ resolvedProject = result.resolved;
372
+ }
373
+ let resolvedTeam = team;
374
+ if (!resolvedTeam) {
375
+ const label = action === "list" ? "list backlogs" : "list backlog work items";
376
+ const result = await elicitTeam(server, connection, resolvedProject, `Select the Azure DevOps team to ${label} for.`);
377
+ if ("response" in result)
378
+ return result.response;
379
+ resolvedTeam = result.resolved;
380
+ }
381
+ const workApi = await connection.getWorkApi();
382
+ const teamContext = { project: resolvedProject, team: resolvedTeam };
383
+ if (action === "list") {
384
+ const backlogs = await workApi.getBacklogs(teamContext);
385
+ return { content: [{ type: "text", text: JSON.stringify(backlogs, null, 2) }] };
386
+ }
387
+ if (action === "list_work_items") {
388
+ if (!backlogId)
389
+ return { content: [{ type: "text", text: "backlogId is required for list_work_items" }], isError: true };
390
+ const workItems = await workApi.getBacklogLevelWorkItems(teamContext, backlogId);
391
+ return { content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }] };
392
+ }
393
+ if (action === "reorder") {
394
+ if (!ids?.length)
395
+ return { content: [{ type: "text", text: "ids is required for reorder" }], isError: true };
396
+ const operation = { ids, previousId, nextId, parentId, iterationPath };
397
+ const reorderedItems = iterationId ? await workApi.reorderIterationWorkItems(operation, teamContext, iterationId) : await workApi.reorderBacklogWorkItems(operation, teamContext);
398
+ return { content: [{ type: "text", text: JSON.stringify(reorderedItems, null, 2) }] };
399
+ }
400
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
401
+ }
402
+ catch (error) {
403
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
404
+ const msgs = {
405
+ list: `Error listing backlogs: ${errorMessage}`,
406
+ list_work_items: `Error listing backlog work items: ${errorMessage}`,
407
+ reorder: `Error reordering backlog work items: ${errorMessage}`,
408
+ };
409
+ return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true };
410
+ }
411
+ });
412
+ // --- wit_work_item_attachment -----------------------------------------------
413
+ server.tool(WORKITEM_TOOLS.wit_work_item_attachment, "Download a work item attachment by its ID. By default returns the content as a base64-encoded resource. If savePath is provided, saves the file locally to that directory and returns the file path instead. Useful for viewing images (e.g. screenshots) or other files attached to work items such as bugs. If a project is not specified, you will be prompted to select one.", {
414
+ project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."),
415
+ attachmentId: z.string().describe("The GUID of the attachment. Found in the attachment URL: https://dev.azure.com/{org}/{project}/_apis/wit/attachments/{attachmentId}"),
416
+ fileName: z.string().optional().describe("The file name of the attachment, e.g. 'screenshot.png'. Used to determine the MIME type or the saved file's name."),
417
+ savePath: z
418
+ .string()
419
+ .optional()
420
+ .describe("Optional local directory path where the file should be saved. Must be a relative path (e.g. 'temp' or 'downloads/attachments'); absolute paths and path traversals are not allowed. If provided, saves the attachment to this directory and returns the file path. If omitted, returns the content as a base64-encoded resource."),
421
+ }, async ({ project, attachmentId, fileName, savePath }) => {
422
+ const isAbsolutePath = (value) => path.posix.isAbsolute(value) || path.win32.isAbsolute(value);
423
+ const hasDriveLetter = (value) => /^[a-zA-Z]:/.test(value);
424
+ if (savePath !== undefined && (savePath.includes("..") || isAbsolutePath(savePath) || hasDriveLetter(savePath))) {
425
+ throw new Error("Invalid savePath: absolute paths and path traversals are not allowed.");
426
+ }
427
+ if (fileName !== undefined && fileName.includes("..")) {
428
+ throw new Error("Invalid fileName: path traversal is not allowed.");
429
+ }
430
+ try {
431
+ const connection = await connectionProvider();
432
+ let resolvedProject = project;
433
+ if (!resolvedProject) {
434
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve the work item attachment from.");
435
+ if ("response" in result)
436
+ return result.response;
437
+ resolvedProject = result.resolved;
438
+ }
439
+ const workItemApi = await connection.getWorkItemTrackingApi();
440
+ const stream = await workItemApi.getAttachmentContent(attachmentId, fileName, resolvedProject);
441
+ const chunks = [];
442
+ await new Promise((resolve, reject) => {
443
+ stream.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
444
+ stream.on("end", resolve);
445
+ stream.on("error", reject);
446
+ });
447
+ const buffer = Buffer.concat(chunks);
448
+ if (savePath) {
449
+ const resolvedFileName = fileName ?? attachmentId;
450
+ const localFilePath = path.join(savePath, resolvedFileName);
451
+ if (fs.existsSync(localFilePath)) {
452
+ throw new Error(`File already exists: ${localFilePath}`);
453
+ }
454
+ fs.writeFileSync(localFilePath, buffer);
455
+ return {
456
+ content: [{ type: "text", text: `Attachment saved to: ${localFilePath}` }],
457
+ };
458
+ }
459
+ const mimeType = getMimeType(fileName);
460
+ if (mimeType.startsWith("text/")) {
461
+ return createExternalContentResponse(buffer.toString("utf-8"), "work item attachment");
462
+ }
463
+ const base64Data = buffer.toString("base64");
464
+ return {
465
+ content: [
466
+ {
467
+ type: "resource",
468
+ resource: {
469
+ uri: `data:${mimeType};base64,${base64Data}`,
470
+ mimeType,
471
+ blob: base64Data,
472
+ },
473
+ },
474
+ ],
475
+ };
476
+ }
477
+ catch (error) {
478
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
479
+ return {
480
+ content: [{ type: "text", text: `Error retrieving work item attachment: ${errorMessage}` }],
481
+ isError: true,
482
+ };
483
+ }
484
+ });
485
+ // --- wit_work_item_write ----------------------------------------------------
486
+ server.tool(WORKITEM_TOOLS.wit_work_item_write, "Write operations for work items. Use the action parameter to specify the operation.", {
487
+ action: z
488
+ .enum(["create", "update", "update_batch", "add_child"])
489
+ .describe("The action to perform. Options: create (create a new work item), update (update fields on a single work item), update_batch (update multiple work items in one call), add_child (create child work items under a parent)."),
490
+ project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."),
491
+ id: z.coerce.number().min(1).optional().describe("Work item ID to update. Required for: update."),
492
+ workItemType: z.string().optional().describe("The type of work item. Required for: create, add_child."),
493
+ fields: z
494
+ .array(z.object({
495
+ name: z.string().describe("The field name, e.g. 'System.Title'."),
496
+ value: z.string().describe("The field value."),
497
+ format: z.enum(["Html", "Markdown"]).optional().describe("Format for large text fields. Optional."),
498
+ }))
499
+ .optional()
500
+ .describe("Field values to set on the work item. Required for: create."),
501
+ updates: z
502
+ .array(z.object({
503
+ op: z
504
+ .string()
505
+ .transform((val) => val.toLowerCase())
506
+ .pipe(z.enum(["add", "replace", "remove", "test"]))
507
+ .default("add")
508
+ .describe("The operation to perform. Use 'test' with path '/rev' to enforce optimistic concurrency."),
509
+ path: z.string().describe("The path to operate on, e.g. '/fields/System.Title' or '/rev' for a revision test."),
510
+ value: z
511
+ .union([z.string(), z.number(), z.boolean(), z.null()])
512
+ .optional()
513
+ .describe("The operation value. Required for add, replace, and test; omit for remove. For a test on '/rev', pass the numeric revision previously read."),
514
+ }))
515
+ .optional()
516
+ .describe('Field updates for a single work item. Required for: update. For a safe read-modify-write, prepend a test operation on "/rev" with value set to the numeric revision returned by the preceding read; Azure DevOps rejects the entire update if the current revision differs.'),
517
+ batchUpdates: z
518
+ .array(z.object({
519
+ op: z.enum(["Add", "Replace", "Remove"]).default("Add").describe("The operation to perform."),
520
+ id: z.coerce.number().min(1).describe("The work item ID to update."),
521
+ path: z.string().describe("The field path, e.g. '/fields/System.Title'."),
522
+ value: z.string().describe("The new value for the field."),
523
+ format: z.enum(["Html", "Markdown"]).optional().describe("Format for large text fields. Optional."),
524
+ }))
525
+ .optional()
526
+ .describe("Updates for multiple work items. Required for: update_batch."),
527
+ parentId: z.coerce.number().min(1).optional().describe("The ID of the parent work item. Required for: add_child."),
528
+ items: z
529
+ .array(z.object({
530
+ title: z.string().describe("The title of the child work item."),
531
+ description: z.string().describe("The description of the child work item."),
532
+ format: z.enum(["Markdown", "Html"]).default("Markdown").describe("Format for the description. Defaults to 'Markdown'."),
533
+ areaPath: z.string().optional().describe("Optional area path for the child work item."),
534
+ iterationPath: z.string().optional().describe("Optional iteration path for the child work item."),
535
+ }))
536
+ .optional()
537
+ .describe("Child work items to create. Required for: add_child."),
538
+ }, async ({ action, project, id, workItemType, fields, updates, batchUpdates, parentId, items }) => {
539
+ try {
540
+ const connection = await connectionProvider();
541
+ let resolvedProject = project;
542
+ if (action === "create") {
543
+ if (!workItemType)
544
+ return { content: [{ type: "text", text: "workItemType is required for create" }], isError: true };
545
+ if (!fields || fields.length === 0)
546
+ return { content: [{ type: "text", text: "fields is required for create" }], isError: true };
547
+ if (!resolvedProject) {
548
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to create the work item in.");
549
+ if ("response" in result)
550
+ return result.response;
551
+ resolvedProject = result.resolved;
552
+ }
553
+ const workItemApi = await connection.getWorkItemTrackingApi();
554
+ const document = fields.map(({ name, value, format }) => ({
555
+ op: "add",
556
+ path: `/fields/${name}`,
557
+ value: encodeFormattedValue(value, format),
558
+ }));
559
+ fields.forEach(({ name, format }) => {
560
+ if (format === "Markdown") {
561
+ document.push({
562
+ op: "add",
563
+ path: `/multilineFieldsFormat/${name}`,
564
+ value: "Markdown",
565
+ });
566
+ }
567
+ });
568
+ const newWorkItem = await workItemApi.createWorkItem(null, document, resolvedProject, workItemType);
569
+ if (!newWorkItem) {
570
+ return { content: [{ type: "text", text: "Work item was not created" }], isError: true };
571
+ }
572
+ return { content: [{ type: "text", text: JSON.stringify(newWorkItem, null, 2) }] };
573
+ }
574
+ if (action === "update") {
575
+ if (!id)
576
+ return { content: [{ type: "text", text: "id is required for update" }], isError: true };
577
+ if (!updates || updates.length === 0)
578
+ return { content: [{ type: "text", text: "updates is required for update" }], isError: true };
579
+ const updateWithoutValue = updates.find((update) => update.op !== "remove" && update.value === undefined);
580
+ if (updateWithoutValue) {
581
+ return { content: [{ type: "text", text: `value is required for ${updateWithoutValue.op}` }], isError: true };
582
+ }
583
+ const workItemApi = await connection.getWorkItemTrackingApi();
584
+ const apiUpdates = updates.map((update) => ({ ...update, op: update.op }));
585
+ const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id);
586
+ return { content: [{ type: "text", text: JSON.stringify(updatedWorkItem, null, 2) }] };
587
+ }
588
+ if (action === "update_batch") {
589
+ if (!batchUpdates || batchUpdates.length === 0)
590
+ return { content: [{ type: "text", text: "batchUpdates is required for update_batch" }], isError: true };
591
+ const orgUrl = connection.serverUrl;
592
+ const accessToken = await tokenProvider();
593
+ const uniqueIds = Array.from(new Set(batchUpdates.map((update) => update.id)));
594
+ const body = uniqueIds.map((uid) => {
595
+ const workItemUpdates = batchUpdates.filter((update) => update.id === uid);
596
+ const operations = workItemUpdates.map(({ op, path: fieldPath, value, format }) => ({
597
+ op: op,
598
+ path: fieldPath,
599
+ value: encodeFormattedValue(value, format),
600
+ }));
601
+ workItemUpdates.forEach(({ path: fieldPath, format }) => {
602
+ if (format === "Markdown") {
603
+ operations.push({
604
+ op: "Add",
605
+ path: `/multilineFieldsFormat${fieldPath.replace("/fields", "")}`,
606
+ value: "Markdown",
607
+ });
608
+ }
609
+ });
610
+ return {
611
+ method: "PATCH",
612
+ uri: `/_apis/wit/workitems/${uid}?api-version=${batchApiVersion}`,
613
+ headers: { "Content-Type": "application/json-patch+json" },
614
+ body: operations,
615
+ };
616
+ });
617
+ const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, {
618
+ method: "PATCH",
619
+ headers: {
620
+ "Authorization": `Bearer ${accessToken}`,
621
+ "Content-Type": "application/json",
622
+ "User-Agent": userAgentProvider(),
623
+ },
624
+ body: JSON.stringify(body),
625
+ });
626
+ if (!response.ok) {
627
+ throw new Error(`Failed to update work items in batch: ${response.statusText}`);
628
+ }
629
+ const result = await response.json();
630
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
631
+ }
632
+ if (action === "add_child") {
633
+ if (!parentId)
634
+ return { content: [{ type: "text", text: "parentId is required for add_child" }], isError: true };
635
+ if (!workItemType)
636
+ return { content: [{ type: "text", text: "workItemType is required for add_child" }], isError: true };
637
+ if (!items || items.length === 0)
638
+ return { content: [{ type: "text", text: "items is required for add_child" }], isError: true };
639
+ if (!resolvedProject) {
640
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to create child work items in.");
641
+ if ("response" in result)
642
+ return result.response;
643
+ resolvedProject = result.resolved;
644
+ }
645
+ if (items.length > 50) {
646
+ return { content: [{ type: "text", text: "A maximum of 50 child work items can be created in a single call." }], isError: true };
647
+ }
648
+ const orgUrl = connection.serverUrl;
649
+ const accessToken = await tokenProvider();
650
+ const body = items.map((item, x) => {
651
+ const encodedDescription = encodeFormattedValue(item.description, item.format);
652
+ const ops = [
653
+ { op: "add", path: "/id", value: `-${x + 1}` },
654
+ { op: "add", path: "/fields/System.Title", value: item.title },
655
+ {
656
+ op: "add",
657
+ path: "/relations/-",
658
+ value: {
659
+ rel: "System.LinkTypes.Hierarchy-Reverse",
660
+ url: `${connection.serverUrl}/${resolvedProject}/_apis/wit/workItems/${parentId}`,
661
+ },
662
+ },
663
+ ];
664
+ if (item.areaPath && item.areaPath.trim().length > 0) {
665
+ ops.push({ op: "add", path: "/fields/System.AreaPath", value: item.areaPath });
666
+ }
667
+ if (item.iterationPath && item.iterationPath.trim().length > 0) {
668
+ ops.push({ op: "add", path: "/fields/System.IterationPath", value: item.iterationPath });
669
+ }
670
+ // check if the work item type is "Bug" to determine which field to use for the description
671
+ // ReproSteps is used for Bugs, while Description is used for other work item types
672
+ if (workItemType.toLowerCase() === "bug") {
673
+ ops.push({ op: "add", path: "/fields/Microsoft.VSTS.TCM.ReproSteps", value: encodedDescription });
674
+ if (item.format && item.format === "Markdown") {
675
+ ops.push({ op: "add", path: "/multilineFieldsFormat/Microsoft.VSTS.TCM.ReproSteps", value: item.format });
676
+ }
677
+ }
678
+ else {
679
+ ops.push({ op: "add", path: "/fields/System.Description", value: encodedDescription });
680
+ if (item.format && item.format === "Markdown") {
681
+ ops.push({ op: "add", path: "/multilineFieldsFormat/System.Description", value: item.format });
682
+ }
683
+ }
684
+ return {
685
+ method: "PATCH",
686
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
687
+ uri: `/${encodeURIComponent(resolvedProject)}/_apis/wit/workitems/$${encodeURIComponent(workItemType)}?api-version=${batchApiVersion}`,
688
+ headers: { "Content-Type": "application/json-patch+json" },
689
+ body: ops,
690
+ };
691
+ });
692
+ const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, {
693
+ method: "PATCH",
694
+ headers: {
695
+ "Authorization": `Bearer ${accessToken}`,
696
+ "Content-Type": "application/json",
697
+ "User-Agent": userAgentProvider(),
698
+ },
699
+ body: JSON.stringify(body),
700
+ });
701
+ if (!response.ok) {
702
+ throw new Error(`Failed to update work items in batch: ${response.statusText}`);
703
+ }
704
+ const result = await response.json();
705
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
706
+ }
707
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
708
+ }
709
+ catch (error) {
710
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
711
+ const statusCode = typeof error === "object" && error !== null && "statusCode" in error && typeof error.statusCode === "number" ? error.statusCode : undefined;
712
+ const statusText = statusCode === 409 ? " Conflict" : statusCode === 412 ? " Precondition Failed" : "";
713
+ const updateStatus = statusCode !== undefined ? ` [HTTP ${statusCode}${statusText}]` : "";
714
+ const msgs = {
715
+ create: `Error creating work item: ${errorMessage}`,
716
+ update: `Error updating work item${updateStatus}: ${errorMessage}`,
717
+ update_batch: `Error updating work items in batch: ${errorMessage}`,
718
+ add_child: `Error creating child work items: ${errorMessage}`,
719
+ };
720
+ return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true };
721
+ }
722
+ });
723
+ // --- wit_work_item_comment_write --------------------------------------------
724
+ server.tool(WORKITEM_TOOLS.wit_work_item_comment_write, "Write operations for work item comments. Use the action parameter to specify the operation.", {
725
+ action: z.enum(["add", "update"]).describe("The action to perform. Options: add (add a comment to a work item), update (update an existing comment on a work item)."),
726
+ project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."),
727
+ workItemId: z.coerce.number().min(1).optional().describe("The ID of the work item. Required for: add, update."),
728
+ text: z.string().optional().describe("The comment text. Required for: add, update."),
729
+ commentId: z.coerce.number().min(1).optional().describe("The ID of the comment to update. Required for: update."),
730
+ format: z.enum(["Markdown", "Html"]).optional().default("Markdown").describe("Format of the comment text. Optional, defaults to 'Markdown'."),
731
+ }, async ({ action, project, workItemId, text, commentId, format }) => {
732
+ try {
733
+ const connection = await connectionProvider();
734
+ let resolvedProject = project;
735
+ if (!resolvedProject) {
736
+ const label = action === "add" ? "add a work item comment in" : "update the work item comment in";
737
+ const result = await elicitProject(server, connection, `Select the Azure DevOps project to ${label}.`);
738
+ if ("response" in result)
739
+ return result.response;
740
+ resolvedProject = result.resolved;
741
+ }
742
+ if (!workItemId)
743
+ return { content: [{ type: "text", text: "workItemId is required" }], isError: true };
744
+ if (!text)
745
+ return { content: [{ type: "text", text: "text is required" }], isError: true };
746
+ const orgUrl = connection.serverUrl;
747
+ const accessToken = await tokenProvider();
748
+ const formatParameter = (format ?? "Markdown") === "Markdown" ? 0 : 1;
749
+ const resolvedText = await resolveCommentMentions(text, format, tokenProvider, connectionProvider, userAgentProvider);
750
+ if (action === "add") {
751
+ const response = await fetch(`${orgUrl}/${encodeURIComponent(resolvedProject)}/_apis/wit/workItems/${workItemId}/comments?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, {
752
+ method: "POST",
753
+ headers: {
754
+ "Authorization": `Bearer ${accessToken}`,
755
+ "Content-Type": "application/json",
756
+ "User-Agent": userAgentProvider(),
757
+ },
758
+ body: JSON.stringify({ text: resolvedText }),
759
+ });
760
+ if (!response.ok) {
761
+ throw new Error(`Failed to add a work item comment: ${response.statusText}`);
762
+ }
763
+ return { content: [{ type: "text", text: await response.text() }] };
764
+ }
765
+ if (action === "update") {
766
+ if (!commentId)
767
+ return { content: [{ type: "text", text: "commentId is required for update" }], isError: true };
768
+ const response = await fetch(`${orgUrl}/${encodeURIComponent(resolvedProject)}/_apis/wit/workItems/${workItemId}/comments/${commentId}?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, {
769
+ method: "PATCH",
770
+ headers: {
771
+ "Authorization": `Bearer ${accessToken}`,
772
+ "Content-Type": "application/json",
773
+ "User-Agent": userAgentProvider(),
774
+ },
775
+ body: JSON.stringify({ text: resolvedText }),
776
+ });
777
+ if (!response.ok) {
778
+ throw new Error(`Failed to update work item comment: ${response.statusText}`);
779
+ }
780
+ return { content: [{ type: "text", text: await response.text() }] };
781
+ }
782
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
783
+ }
784
+ catch (error) {
785
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
786
+ const msgs = {
787
+ add: `Error adding work item comment: ${errorMessage}`,
788
+ update: `Error updating work item comment: ${errorMessage}`,
789
+ };
790
+ return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true };
791
+ }
792
+ });
793
+ // --- wit_work_item_link_write -----------------------------------------------
794
+ server.tool(WORKITEM_TOOLS.wit_work_item_link_write, "Write operations for work item links. Use the action parameter to specify the operation.", {
795
+ action: z
796
+ .enum(["link", "unlink", "link_to_pull_request", "add_artifact_link"])
797
+ .describe("The action to perform. Options: link (link two work items together), unlink (remove links from a work item), link_to_pull_request (link a work item to a pull request), add_artifact_link (add a repository, branch, commit, or build artifact link to a work item)."),
798
+ project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."),
799
+ // link
800
+ updates: z
801
+ .array(z.object({
802
+ id: z.coerce.number().min(1).describe("The ID of the work item to update."),
803
+ linkToId: z.coerce.number().min(1).optional().describe("The ID of the work item to link to. Required unless type is 'hyperlink'."),
804
+ url: z.string().optional().describe("The URL for a hyperlink. Required when type is 'hyperlink'."),
805
+ type: z
806
+ .enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by", "hyperlink"])
807
+ .default("related")
808
+ .describe("Type of link. Defaults to 'related'."),
809
+ comment: z.string().optional().describe("Optional comment for the link."),
810
+ }))
811
+ .optional()
812
+ .describe("Link operations to apply. Required for: link."),
813
+ // unlink
814
+ id: z.coerce.number().min(1).optional().describe("Work item ID to remove links from. Required for: unlink."),
815
+ type: z
816
+ .enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by", "artifact", "hyperlink"])
817
+ .optional()
818
+ .describe("Link type to remove. Required for: unlink."),
819
+ url: z.string().optional().describe("URL to match when removing a link. Used for: unlink. If not provided, all links of the specified type are removed."),
820
+ // link_to_pull_request and add_artifact_link
821
+ projectId: z.string().optional().describe("The project ID (GUID). Required for: link_to_pull_request, and add_artifact_link (Git/Wiki types)."),
822
+ repositoryId: z.string().optional().describe("The repository ID. Required for: link_to_pull_request and add_artifact_link (Git types)."),
823
+ pullRequestId: z.coerce.number().min(1).optional().describe("The pull request ID. Required for: link_to_pull_request; used for: add_artifact_link (Pull Request type)."),
824
+ workItemId: z.coerce.number().min(1).optional().describe("The work item ID. Required for: link_to_pull_request, add_artifact_link."),
825
+ pullRequestProjectId: z.string().optional().describe("Project ID containing the pull request. Used for: link_to_pull_request. Defaults to projectId."),
826
+ // add_artifact_link
827
+ artifactUri: z.string().optional().describe("The complete VSTFS URI of the artifact. Used for: add_artifact_link. If provided, individual component parameters are ignored."),
828
+ branchName: z.string().optional().describe("The branch name. Used for: add_artifact_link (Branch type)."),
829
+ commitId: z.string().optional().describe("The commit SHA hash. Used for: add_artifact_link (Fixed in Commit type)."),
830
+ buildId: z.coerce.number().min(1).optional().describe("The build ID. Used for: add_artifact_link (Build, Found in build, Integrated in build types)."),
831
+ wikiId: z.string().optional().describe("The wiki ID (GUID). Used for: add_artifact_link (Wiki type)."),
832
+ pageId: z.coerce.number().min(1).optional().describe("The numeric wiki page ID. Used for: add_artifact_link (Wiki type). Takes precedence over pagePath."),
833
+ pagePath: z.string().optional().describe("The full wiki page path. Used for: add_artifact_link (Wiki type) when pageId is not provided."),
834
+ linkType: z
835
+ .enum([
836
+ "Branch",
837
+ "Build",
838
+ "Fixed in Changeset",
839
+ "Fixed in Commit",
840
+ "Found in build",
841
+ "Integrated in build",
842
+ "Model Link",
843
+ "Pull Request",
844
+ "Related Workitem",
845
+ "Result Attachment",
846
+ "Source Code File",
847
+ "Tag",
848
+ "Test Result",
849
+ "Wiki",
850
+ ])
851
+ .optional()
852
+ .describe("Type of artifact link. Used for: add_artifact_link. Defaults to 'Branch'."),
853
+ comment: z.string().optional().describe("Comment to include with the artifact link. Used for: add_artifact_link."),
854
+ }, async ({ action, project, updates, id, type, url, projectId, repositoryId, pullRequestId, workItemId, pullRequestProjectId, artifactUri, branchName, commitId, buildId, wikiId, pageId, pagePath, linkType, comment, }) => {
855
+ try {
856
+ const connection = await connectionProvider();
857
+ let resolvedProject = project;
858
+ if (action === "link") {
859
+ if (!updates || updates.length === 0)
860
+ return { content: [{ type: "text", text: "updates is required for link" }], isError: true };
861
+ if (!resolvedProject) {
862
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to link work items in.");
863
+ if ("response" in result)
864
+ return result.response;
865
+ resolvedProject = result.resolved;
866
+ }
867
+ const orgUrl = connection.serverUrl;
868
+ const accessToken = await tokenProvider();
869
+ const uniqueIds = Array.from(new Set(updates.map((update) => update.id)));
870
+ const body = uniqueIds.map((uid) => ({
871
+ method: "PATCH",
872
+ uri: `/_apis/wit/workitems/${uid}?api-version=${batchApiVersion}`,
873
+ headers: { "Content-Type": "application/json-patch+json" },
874
+ body: updates
875
+ .filter((update) => update.id === uid)
876
+ .map(({ linkToId, url: linkUrl, type: linkTypeName, comment: linkComment }) => {
877
+ if (linkTypeName === "hyperlink" && !linkUrl) {
878
+ throw new Error("url is required for hyperlink links");
879
+ }
880
+ if (linkTypeName !== "hyperlink" && !linkToId) {
881
+ throw new Error("linkToId is required for work item links");
882
+ }
883
+ return {
884
+ op: "add",
885
+ path: "/relations/-",
886
+ value: {
887
+ rel: getLinkTypeFromName(linkTypeName),
888
+ url: linkTypeName === "hyperlink" ? linkUrl : `${orgUrl}/${resolvedProject}/_apis/wit/workItems/${linkToId}`,
889
+ attributes: { comment: linkComment || "" },
890
+ },
891
+ };
892
+ }),
893
+ }));
894
+ const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, {
895
+ method: "PATCH",
896
+ headers: {
897
+ "Authorization": `Bearer ${accessToken}`,
898
+ "Content-Type": "application/json",
899
+ "User-Agent": userAgentProvider(),
900
+ },
901
+ body: JSON.stringify(body),
902
+ });
903
+ if (!response.ok) {
904
+ throw new Error(`Failed to update work items in batch: ${response.statusText}`);
905
+ }
906
+ const result = await response.json();
907
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
908
+ }
909
+ if (action === "unlink") {
910
+ if (!id)
911
+ return { content: [{ type: "text", text: "id is required for unlink" }], isError: true };
912
+ if (!type)
913
+ return { content: [{ type: "text", text: "type is required for unlink" }], isError: true };
914
+ if (!resolvedProject) {
915
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to unlink work items in.");
916
+ if ("response" in result)
917
+ return result.response;
918
+ resolvedProject = result.resolved;
919
+ }
920
+ const workItemApi = await connection.getWorkItemTrackingApi();
921
+ const workItem = await workItemApi.getWorkItem(id, undefined, undefined, WorkItemExpand.Relations, resolvedProject);
922
+ const relations = workItem.relations ?? [];
923
+ const linkTypeName = getLinkTypeFromName(type);
924
+ let relationIndexes = [];
925
+ if (url && url.trim().length > 0) {
926
+ relationIndexes = relations.map((relation, idx) => (relation.rel === linkTypeName && relation.url === url ? idx : -1)).filter((idx) => idx !== -1);
927
+ }
928
+ else {
929
+ relationIndexes = relations.map((relation, idx) => (relation.rel === linkTypeName ? idx : -1)).filter((idx) => idx !== -1);
930
+ }
931
+ if (relationIndexes.length === 0) {
932
+ return {
933
+ content: [{ type: "text", text: `No matching relations found for link type '${type}'${url ? ` and URL '${url}'` : ""}.\n${JSON.stringify(relations, null, 2)}` }],
934
+ isError: true,
935
+ };
936
+ }
937
+ const removedRelations = relationIndexes.map((idx) => relations[idx]);
938
+ relationIndexes.sort((a, b) => b - a);
939
+ const apiUpdates = relationIndexes.map((idx) => ({ op: "remove", path: `/relations/${idx}` }));
940
+ const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id, resolvedProject);
941
+ return {
942
+ content: [
943
+ {
944
+ type: "text",
945
+ text: `Removed ${removedRelations.length} link(s) of type '${type}':\n` +
946
+ JSON.stringify(removedRelations, null, 2) +
947
+ `\n\nUpdated work item result:\n` +
948
+ JSON.stringify(updatedWorkItem, null, 2),
949
+ },
950
+ ],
951
+ isError: false,
952
+ };
953
+ }
954
+ if (action === "link_to_pull_request") {
955
+ if (!projectId)
956
+ return { content: [{ type: "text", text: "projectId is required for link_to_pull_request" }], isError: true };
957
+ if (!repositoryId)
958
+ return { content: [{ type: "text", text: "repositoryId is required for link_to_pull_request" }], isError: true };
959
+ if (pullRequestId === undefined)
960
+ return { content: [{ type: "text", text: "pullRequestId is required for link_to_pull_request" }], isError: true };
961
+ if (!workItemId)
962
+ return { content: [{ type: "text", text: "workItemId is required for link_to_pull_request" }], isError: true };
963
+ const workItemTrackingApi = await connection.getWorkItemTrackingApi();
964
+ const artifactProjectId = pullRequestProjectId && pullRequestProjectId.trim() !== "" ? pullRequestProjectId : projectId;
965
+ const artifactPathValue = `${artifactProjectId}/${repositoryId}/${pullRequestId}`;
966
+ const vstfsUrl = `vstfs:///Git/PullRequestId/${encodeURIComponent(artifactPathValue)}`;
967
+ const patchDocument = [
968
+ {
969
+ op: "add",
970
+ path: "/relations/-",
971
+ value: {
972
+ rel: "ArtifactLink",
973
+ url: vstfsUrl,
974
+ attributes: { name: "Pull Request" },
975
+ },
976
+ },
977
+ ];
978
+ const workItem = await workItemTrackingApi.updateWorkItem({}, patchDocument, workItemId, projectId);
979
+ if (!workItem) {
980
+ return { content: [{ type: "text", text: "Work item update failed" }], isError: true };
981
+ }
982
+ return {
983
+ content: [{ type: "text", text: JSON.stringify({ workItemId, pullRequestId, success: true }, null, 2) }],
984
+ };
985
+ }
986
+ if (action === "add_artifact_link") {
987
+ if (!workItemId)
988
+ return { content: [{ type: "text", text: "workItemId is required for add_artifact_link" }], isError: true };
989
+ if (!resolvedProject) {
990
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to add the artifact link in.");
991
+ if ("response" in result)
992
+ return result.response;
993
+ resolvedProject = result.resolved;
994
+ }
995
+ const workItemTrackingApi = await connection.getWorkItemTrackingApi();
996
+ const effectiveLinkType = linkType ?? "Branch";
997
+ let finalArtifactUri;
998
+ if (artifactUri) {
999
+ finalArtifactUri = artifactUri;
1000
+ }
1001
+ else {
1002
+ switch (effectiveLinkType) {
1003
+ case "Branch":
1004
+ if (!projectId || !repositoryId || !branchName) {
1005
+ return { content: [{ type: "text", text: "For 'Branch' links, 'projectId', 'repositoryId', and 'branchName' are required." }], isError: true };
1006
+ }
1007
+ finalArtifactUri = `vstfs:///Git/Ref/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2FGB${encodeURIComponent(branchName)}`;
1008
+ break;
1009
+ case "Fixed in Commit":
1010
+ if (!projectId || !repositoryId || !commitId) {
1011
+ return { content: [{ type: "text", text: "For 'Fixed in Commit' links, 'projectId', 'repositoryId', and 'commitId' are required." }], isError: true };
1012
+ }
1013
+ finalArtifactUri = `vstfs:///Git/Commit/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2F${encodeURIComponent(commitId)}`;
1014
+ break;
1015
+ case "Pull Request":
1016
+ if (!projectId || !repositoryId || pullRequestId === undefined) {
1017
+ return { content: [{ type: "text", text: "For 'Pull Request' links, 'projectId', 'repositoryId', and 'pullRequestId' are required." }], isError: true };
1018
+ }
1019
+ finalArtifactUri = `vstfs:///Git/PullRequestId/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2F${encodeURIComponent(pullRequestId.toString())}`;
1020
+ break;
1021
+ case "Build":
1022
+ case "Found in build":
1023
+ case "Integrated in build":
1024
+ if (buildId === undefined) {
1025
+ return { content: [{ type: "text", text: `For '${effectiveLinkType}' links, 'buildId' is required.` }], isError: true };
1026
+ }
1027
+ finalArtifactUri = `vstfs:///Build/Build/${encodeURIComponent(buildId.toString())}`;
1028
+ break;
1029
+ case "Wiki": {
1030
+ if (!projectId || !wikiId) {
1031
+ return { content: [{ type: "text", text: "For 'Wiki' links, 'projectId', 'wikiId', and 'pagePath' are required." }], isError: true };
1032
+ }
1033
+ let resolvedPagePath = pagePath;
1034
+ if (pageId !== undefined) {
1035
+ const orgUrl = connection.serverUrl;
1036
+ const accessToken = await tokenProvider();
1037
+ const pageResponse = await fetch(`${orgUrl}/${encodeURIComponent(resolvedProject)}/_apis/wiki/wikis/${encodeURIComponent(wikiId)}/pages/${pageId}?api-version=7.1`, {
1038
+ headers: {
1039
+ "Authorization": `Bearer ${accessToken}`,
1040
+ "User-Agent": userAgentProvider(),
1041
+ },
1042
+ });
1043
+ if (!pageResponse.ok) {
1044
+ return { content: [{ type: "text", text: `Failed to look up wiki page ID ${pageId}: ${pageResponse.statusText}` }], isError: true };
1045
+ }
1046
+ const pageData = await pageResponse.json();
1047
+ resolvedPagePath = pageData.path;
1048
+ }
1049
+ if (!resolvedPagePath) {
1050
+ return { content: [{ type: "text", text: "For 'Wiki' links, 'pageId' or 'pagePath' is required." }], isError: true };
1051
+ }
1052
+ const normalizedPath = resolvedPagePath.startsWith("/") ? resolvedPagePath.slice(1) : resolvedPagePath;
1053
+ const encodedPath = normalizedPath.split("/").map(encodeURIComponent).join("%2F");
1054
+ finalArtifactUri = `vstfs:///Wiki/WikiPage/${encodeURIComponent(projectId)}%2F${encodeURIComponent(wikiId)}%2F${encodedPath}`;
1055
+ break;
1056
+ }
1057
+ default:
1058
+ return {
1059
+ content: [{ type: "text", text: `URI building from components is not supported for link type '${effectiveLinkType}'. Please provide the full 'artifactUri' instead.` }],
1060
+ isError: true,
1061
+ };
1062
+ }
1063
+ }
1064
+ const patchDocument = [
1065
+ {
1066
+ op: "add",
1067
+ path: "/relations/-",
1068
+ value: {
1069
+ rel: "ArtifactLink",
1070
+ url: finalArtifactUri,
1071
+ attributes: {
1072
+ name: getArtifactLinkAttributeName(effectiveLinkType),
1073
+ ...(comment && { comment }),
1074
+ },
1075
+ },
1076
+ },
1077
+ ];
1078
+ const workItem = await workItemTrackingApi.updateWorkItem({}, patchDocument, workItemId, resolvedProject);
1079
+ if (!workItem) {
1080
+ return { content: [{ type: "text", text: "Work item update failed" }], isError: true };
1081
+ }
1082
+ return {
1083
+ content: [
1084
+ {
1085
+ type: "text",
1086
+ text: JSON.stringify({ workItemId, artifactUri: finalArtifactUri, linkType: effectiveLinkType, comment: comment || null, success: true }, null, 2),
1087
+ },
1088
+ ],
1089
+ };
1090
+ }
1091
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
1092
+ }
1093
+ catch (error) {
1094
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
1095
+ const msgs = {
1096
+ link: `Error linking work items: ${errorMessage}`,
1097
+ unlink: `Error unlinking work item: ${errorMessage}`,
1098
+ link_to_pull_request: `Error linking work item to pull request: ${errorMessage}`,
1099
+ add_artifact_link: `Error adding artifact link to work item: ${errorMessage}`,
1100
+ };
1101
+ return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true };
1102
+ }
1103
+ });
1104
+ }
1105
+ function getMimeType(fileName) {
1106
+ const ext = fileName?.split(".").pop()?.toLowerCase();
1107
+ const mimeTypes = {
1108
+ png: "image/png",
1109
+ jpg: "image/jpeg",
1110
+ jpeg: "image/jpeg",
1111
+ gif: "image/gif",
1112
+ bmp: "image/bmp",
1113
+ svg: "image/svg+xml",
1114
+ webp: "image/webp",
1115
+ pdf: "application/pdf",
1116
+ txt: "text/plain",
1117
+ md: "text/markdown",
1118
+ markdown: "text/markdown",
1119
+ csv: "text/csv",
1120
+ html: "text/html",
1121
+ htm: "text/html",
1122
+ xml: "text/xml",
1123
+ json: "application/json",
1124
+ yaml: "text/yaml",
1125
+ yml: "text/yaml",
1126
+ zip: "application/zip",
1127
+ };
1128
+ return (ext && mimeTypes[ext]) ?? "application/octet-stream";
1129
+ }
1130
+ export { WORKITEM_TOOLS, configureWorkItemTools };