@azure-devops/mcp 2.8.1 → 2.9.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.
@@ -9,29 +9,13 @@ import { batchApiVersion, markdownCommentsApiVersion, getEnumKeys, safeEnumConve
9
9
  import { elicitProject, elicitTeam } from "../shared/elicitations.js";
10
10
  import { createExternalContentResponse } from "../shared/content-safety.js";
11
11
  const WORKITEM_TOOLS = {
12
- my_work_items: "wit_my_work_items",
13
- list_backlogs: "wit_list_backlogs",
14
- list_backlog_work_items: "wit_list_backlog_work_items",
15
- get_work_item: "wit_get_work_item",
16
- get_work_items_batch_by_ids: "wit_get_work_items_batch_by_ids",
17
- update_work_item: "wit_update_work_item",
18
- create_work_item: "wit_create_work_item",
19
- list_work_item_comments: "wit_list_work_item_comments",
20
- list_work_item_revisions: "wit_list_work_item_revisions",
21
- get_work_items_for_iteration: "wit_get_work_items_for_iteration",
22
- add_work_item_comment: "wit_add_work_item_comment",
23
- update_work_item_comment: "wit_update_work_item_comment",
24
- add_child_work_items: "wit_add_child_work_items",
25
- link_work_item_to_pull_request: "wit_link_work_item_to_pull_request",
26
- get_work_item_type: "wit_get_work_item_type",
27
- get_query: "wit_get_query",
28
- get_query_results_by_id: "wit_get_query_results_by_id",
29
- update_work_items_batch: "wit_update_work_items_batch",
30
- work_items_link: "wit_work_items_link",
31
- work_item_unlink: "wit_work_item_unlink",
32
- add_artifact_link: "wit_add_artifact_link",
33
- get_work_item_attachment: "wit_get_work_item_attachment",
34
- query_by_wiql: "wit_query_by_wiql",
12
+ wit_work_item: "wit_work_item",
13
+ wit_query: "wit_query",
14
+ wit_backlog: "wit_backlog",
15
+ wit_work_item_attachment: "wit_work_item_attachment",
16
+ wit_work_item_write: "wit_work_item_write",
17
+ wit_work_item_comment_write: "wit_work_item_comment_write",
18
+ wit_work_item_link_write: "wit_work_item_link_write",
35
19
  };
36
20
  function getLinkTypeFromName(name) {
37
21
  switch (name.toLowerCase()) {
@@ -72,1205 +56,307 @@ function getArtifactLinkAttributeName(linkType) {
72
56
  }
73
57
  }
74
58
  function configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider) {
75
- server.tool(WORKITEM_TOOLS.list_backlogs, "Receive a list of backlogs for a given project and team. If a project or team is not specified, you will be prompted to select one.", {
59
+ // --- wit_work_item ----------------------------------------------------------
60
+ server.tool(WORKITEM_TOOLS.wit_work_item, "Retrieve work item data for a project. Use the action parameter to specify the operation.", {
61
+ action: z
62
+ .enum(["get", "get_batch", "list_comments", "my", "list_revisions", "list_for_iteration", "get_type"])
63
+ .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)."),
76
64
  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."),
77
- 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."),
78
- }, async ({ project, team }) => {
79
- try {
80
- const connection = await connectionProvider();
81
- let resolvedProject = project;
82
- if (!resolvedProject) {
83
- const result = await elicitProject(server, connection, "Select the Azure DevOps project to list backlogs for.");
84
- if ("response" in result)
85
- return result.response;
86
- resolvedProject = result.resolved;
87
- }
88
- let resolvedTeam = team;
89
- if (!resolvedTeam) {
90
- const result = await elicitTeam(server, connection, resolvedProject, "Select the Azure DevOps team to list backlogs for.");
91
- if ("response" in result)
92
- return result.response;
93
- resolvedTeam = result.resolved;
94
- }
95
- const workApi = await connection.getWorkApi();
96
- const teamContext = { project: resolvedProject, team: resolvedTeam };
97
- const backlogs = await workApi.getBacklogs(teamContext);
98
- return {
99
- content: [{ type: "text", text: JSON.stringify(backlogs, null, 2) }],
100
- };
101
- }
102
- catch (error) {
103
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
104
- return {
105
- content: [{ type: "text", text: `Error listing backlogs: ${errorMessage}` }],
106
- isError: true,
107
- };
108
- }
109
- });
110
- server.tool(WORKITEM_TOOLS.list_backlog_work_items, "Retrieve a list of backlogs of for a given project, team, and backlog category. If a project or team is not specified, you will be prompted to select one.", {
111
- 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."),
112
- 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."),
113
- backlogId: z.string().describe("The ID of the backlog category to retrieve work items from."),
114
- }, async ({ project, team, backlogId }) => {
115
- try {
116
- const connection = await connectionProvider();
117
- let resolvedProject = project;
118
- if (!resolvedProject) {
119
- const result = await elicitProject(server, connection, "Select the Azure DevOps project to list backlog work items for.");
120
- if ("response" in result)
121
- return result.response;
122
- resolvedProject = result.resolved;
123
- }
124
- let resolvedTeam = team;
125
- if (!resolvedTeam) {
126
- const result = await elicitTeam(server, connection, resolvedProject, "Select the Azure DevOps team to list backlog work items for.");
127
- if ("response" in result)
128
- return result.response;
129
- resolvedTeam = result.resolved;
130
- }
131
- const workApi = await connection.getWorkApi();
132
- const teamContext = { project: resolvedProject, team: resolvedTeam };
133
- const workItems = await workApi.getBacklogLevelWorkItems(teamContext, backlogId);
134
- return {
135
- content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }],
136
- };
137
- }
138
- catch (error) {
139
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
140
- return {
141
- content: [{ type: "text", text: `Error listing backlog work items: ${errorMessage}` }],
142
- isError: true,
143
- };
144
- }
145
- });
146
- server.tool(WORKITEM_TOOLS.my_work_items, "Retrieve a list of work items relevant to the authenticated user. If a project is not specified, you will be prompted to select one.", {
147
- 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."),
148
- type: z.enum(["assignedtome", "myactivity"]).default("assignedtome").describe("The type of work items to retrieve. Defaults to 'assignedtome'."),
149
- top: z.coerce.number().default(50).describe("The maximum number of work items to return. Defaults to 50."),
150
- includeCompleted: z.boolean().default(false).describe("Whether to include completed work items. Defaults to false."),
151
- }, async ({ project, type, top, includeCompleted }) => {
152
- try {
153
- const connection = await connectionProvider();
154
- let resolvedProject = project;
155
- if (!resolvedProject) {
156
- const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve work items for.");
157
- if ("response" in result)
158
- return result.response;
159
- resolvedProject = result.resolved;
160
- }
161
- const workApi = await connection.getWorkApi();
162
- const workItems = await workApi.getPredefinedQueryResults(resolvedProject, type, top, includeCompleted);
163
- return {
164
- content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }],
165
- };
166
- }
167
- catch (error) {
168
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
169
- return {
170
- content: [{ type: "text", text: `Error retrieving work items: ${errorMessage}` }],
171
- isError: true,
172
- };
173
- }
174
- });
175
- server.tool(WORKITEM_TOOLS.get_work_items_batch_by_ids, "Retrieve list of work items by IDs in batch. If a project is not specified, you will be prompted to select one.", {
176
- 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."),
177
- ids: z.array(z.coerce.number().min(1)).describe("The IDs of the work items to retrieve."),
178
- fields: z.array(z.string()).optional().describe("Optional list of fields to include in the response. If not provided, a hardcoded default set of fields will be used."),
179
- }, async ({ project, ids, fields }) => {
180
- try {
181
- const connection = await connectionProvider();
182
- let resolvedProject = project;
183
- if (!resolvedProject) {
184
- const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve work items for.");
185
- if ("response" in result)
186
- return result.response;
187
- resolvedProject = result.resolved;
188
- }
189
- const workItemApi = await connection.getWorkItemTrackingApi();
190
- const defaultFields = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.Parent", "System.Tags", "Microsoft.VSTS.Common.StackRank", "System.AssignedTo"];
191
- // If no fields are provided, use the default set of fields
192
- const fieldsToUse = !fields || fields.length === 0 ? defaultFields : fields;
193
- const workitems = await workItemApi.getWorkItemsBatch({ ids, fields: fieldsToUse }, resolvedProject);
194
- // List of identity fields that need to be transformed from objects to formatted strings
195
- const identityFields = [
196
- "System.AssignedTo",
197
- "System.CreatedBy",
198
- "System.ChangedBy",
199
- "System.AuthorizedAs",
200
- "Microsoft.VSTS.Common.ActivatedBy",
201
- "Microsoft.VSTS.Common.ResolvedBy",
202
- "Microsoft.VSTS.Common.ClosedBy",
203
- ];
204
- // Format identity fields to include displayName and uniqueName
205
- // Removing the identity object as the response. It's too much and not needed
206
- if (workitems && Array.isArray(workitems)) {
207
- workitems.forEach((item) => {
208
- if (item.fields) {
209
- identityFields.forEach((fieldName) => {
210
- if (item.fields && item.fields[fieldName] && typeof item.fields[fieldName] === "object") {
211
- const identityField = item.fields[fieldName];
212
- const name = identityField.displayName || "";
213
- const email = identityField.uniqueName || "";
214
- item.fields[fieldName] = `${name} <${email}>`.trim();
215
- }
216
- });
217
- }
218
- });
219
- }
220
- return {
221
- content: [{ type: "text", text: JSON.stringify(workitems, null, 2) }],
222
- };
223
- }
224
- catch (error) {
225
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
226
- return {
227
- content: [{ type: "text", text: `Error retrieving work items batch: ${errorMessage}` }],
228
- isError: true,
229
- };
230
- }
231
- });
232
- server.tool(WORKITEM_TOOLS.get_work_item, "Get a single work item by ID. If a project is not specified, you will be prompted to select one.", {
233
- id: z.coerce.number().min(1).describe("The ID of the work item to retrieve."),
234
- 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."),
235
- fields: z
236
- .array(z.string())
237
- .optional()
238
- .describe("Optional list of fields to include in the response. If not provided, all fields will be returned. Cannot be used together with the expand parameter."),
239
- asOf: z.coerce.date().optional().describe("Optional date string to retrieve the work item as of a specific time. If not provided, the current state will be returned."),
240
- expand: z
241
- .enum(["all", "fields", "links", "none", "relations"])
242
- .describe("Optional expand parameter to include additional details in the response. Cannot be used together with the fields parameter.")
243
- .optional()
244
- .describe("Expand options include 'All', 'Fields', 'Links', 'None', and 'Relations'. Relations can be used to get child workitems. Defaults to 'None'. Cannot be used together with the fields parameter."),
245
- }, async ({ id, project, fields, asOf, expand }) => {
246
- try {
247
- const connection = await connectionProvider();
248
- let resolvedProject = project;
249
- if (!resolvedProject) {
250
- const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve the work item from.");
251
- if ("response" in result)
252
- return result.response;
253
- resolvedProject = result.resolved;
254
- }
255
- // The Azure DevOps API does not support using expand and fields together.
256
- // When both are provided, prefer fields as it is the more specific selection.
257
- if (fields && fields.length > 0 && expand != null) {
258
- expand = "none";
259
- }
260
- const workItemApi = await connection.getWorkItemTrackingApi();
261
- const workItem = await workItemApi.getWorkItem(id, fields, asOf, expand, resolvedProject);
262
- return {
263
- content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }],
264
- };
265
- }
266
- catch (error) {
267
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
268
- return {
269
- content: [{ type: "text", text: `Error retrieving work item: ${errorMessage}` }],
270
- isError: true,
271
- };
272
- }
273
- });
274
- server.tool(WORKITEM_TOOLS.list_work_item_comments, "Retrieve list of comments for a work item by ID. If a project is not specified, you will be prompted to select one.", {
275
- 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."),
276
- workItemId: z.coerce.number().min(1).describe("The ID of the work item to retrieve comments for."),
277
- top: z.coerce.number().default(50).describe("Optional number of comments to retrieve. Defaults to all comments."),
278
- }, async ({ project, workItemId, top }) => {
279
- try {
280
- const connection = await connectionProvider();
281
- let resolvedProject = project;
282
- if (!resolvedProject) {
283
- const result = await elicitProject(server, connection, "Select the Azure DevOps project to list work item comments for.");
284
- if ("response" in result)
285
- return result.response;
286
- resolvedProject = result.resolved;
287
- }
288
- const workItemApi = await connection.getWorkItemTrackingApi();
289
- const comments = await workItemApi.getComments(resolvedProject, workItemId, top);
290
- return {
291
- content: [{ type: "text", text: JSON.stringify(comments, null, 2) }],
292
- };
293
- }
294
- catch (error) {
295
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
296
- return {
297
- content: [{ type: "text", text: `Error listing work item comments: ${errorMessage}` }],
298
- isError: true,
299
- };
300
- }
301
- });
302
- server.tool(WORKITEM_TOOLS.add_work_item_comment, "Add comment to a work item by ID. If a project is not specified, you will be prompted to select one.", {
303
- 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."),
304
- workItemId: z.coerce.number().min(1).describe("The ID of the work item to add a comment to."),
305
- comment: z.string().describe("The text of the comment to add to the work item."),
306
- format: z.enum(["Markdown", "Html"]).optional().default("Markdown").describe("The format of the comment text, e.g., 'Markdown', 'Html'. Optional, defaults to 'Markdown'."),
307
- }, async ({ project, workItemId, comment, format }) => {
308
- try {
309
- const connection = await connectionProvider();
310
- let resolvedProject = project;
311
- if (!resolvedProject) {
312
- const result = await elicitProject(server, connection, "Select the Azure DevOps project to add a work item comment in.");
313
- if ("response" in result)
314
- return result.response;
315
- resolvedProject = result.resolved;
316
- }
317
- const orgUrl = connection.serverUrl;
318
- const accessToken = await tokenProvider();
319
- const body = {
320
- text: comment,
321
- };
322
- const formatParameter = (format ?? "Markdown") === "Markdown" ? 0 : 1;
323
- const response = await fetch(`${orgUrl}/${encodeURIComponent(resolvedProject)}/_apis/wit/workItems/${workItemId}/comments?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, {
324
- method: "POST",
325
- headers: {
326
- "Authorization": `Bearer ${accessToken}`,
327
- "Content-Type": "application/json",
328
- "User-Agent": userAgentProvider(),
329
- },
330
- body: JSON.stringify(body),
331
- });
332
- if (!response.ok) {
333
- throw new Error(`Failed to add a work item comment: ${response.statusText}}`);
334
- }
335
- const comments = await response.text();
336
- return {
337
- content: [{ type: "text", text: comments }],
338
- };
339
- }
340
- catch (error) {
341
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
342
- return {
343
- content: [{ type: "text", text: `Error adding work item comment: ${errorMessage}` }],
344
- isError: true,
345
- };
346
- }
347
- });
348
- server.tool(WORKITEM_TOOLS.update_work_item_comment, "Update an existing comment on a work item by ID. If a project is not specified, you will be prompted to select one.", {
349
- 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."),
350
- workItemId: z.coerce.number().min(1).describe("The ID of the work item."),
351
- commentId: z.coerce.number().min(1).describe("The ID of the comment to update."),
352
- text: z.string().describe("The updated comment text."),
353
- format: z.enum(["Markdown", "Html"]).optional().default("Markdown").describe("The format of the comment text, e.g., 'Markdown', 'Html'. Optional, defaults to 'Markdown'."),
354
- }, async ({ project, workItemId, commentId, text, format }) => {
355
- try {
356
- const connection = await connectionProvider();
357
- let resolvedProject = project;
358
- if (!resolvedProject) {
359
- const result = await elicitProject(server, connection, "Select the Azure DevOps project to update the work item comment in.");
360
- if ("response" in result)
361
- return result.response;
362
- resolvedProject = result.resolved;
363
- }
364
- const orgUrl = connection.serverUrl;
365
- const accessToken = await tokenProvider();
366
- const body = { text };
367
- const formatParameter = (format ?? "Markdown") === "Markdown" ? 0 : 1;
368
- const response = await fetch(`${orgUrl}/${encodeURIComponent(resolvedProject)}/_apis/wit/workItems/${workItemId}/comments/${commentId}?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, {
369
- method: "PATCH",
370
- headers: {
371
- "Authorization": `Bearer ${accessToken}`,
372
- "Content-Type": "application/json",
373
- "User-Agent": userAgentProvider(),
374
- },
375
- body: JSON.stringify(body),
376
- });
377
- if (!response.ok) {
378
- throw new Error(`Failed to update work item comment: ${response.statusText}`);
379
- }
380
- const updatedComment = await response.text();
381
- return {
382
- content: [{ type: "text", text: updatedComment }],
383
- };
384
- }
385
- catch (error) {
386
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
387
- return {
388
- content: [{ type: "text", text: `Error updating work item comment: ${errorMessage}` }],
389
- isError: true,
390
- };
391
- }
392
- });
393
- server.tool(WORKITEM_TOOLS.list_work_item_revisions, "Retrieve list of revisions for a work item by ID. If a project is not specified, you will be prompted to select one.", {
394
- 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."),
395
- workItemId: z.coerce.number().min(1).describe("The ID of the work item to retrieve revisions for."),
396
- top: z.coerce.number().default(50).describe("Optional number of revisions to retrieve. If not provided, all revisions will be returned."),
397
- skip: z.coerce.number().optional().describe("Optional number of revisions to skip for pagination. Defaults to 0."),
398
- expand: z
399
- .enum(getEnumKeys(WorkItemExpand))
400
- .default("None")
401
- .optional()
402
- .describe("Optional expand parameter to include additional details. Defaults to 'None'."),
403
- }, async ({ project, workItemId, top, skip, expand }) => {
404
- try {
405
- const connection = await connectionProvider();
406
- let resolvedProject = project;
407
- if (!resolvedProject) {
408
- const result = await elicitProject(server, connection, "Select the Azure DevOps project to list work item revisions for.");
409
- if ("response" in result)
410
- return result.response;
411
- resolvedProject = result.resolved;
412
- }
413
- const workItemApi = await connection.getWorkItemTrackingApi();
414
- const revisions = await workItemApi.getRevisions(workItemId, top, skip, safeEnumConvert(WorkItemExpand, expand), resolvedProject);
415
- // Dynamically clean up identity objects in revision fields
416
- // Identity objects typically have properties like displayName, url, _links, id, uniqueName, imageUrl, descriptor
417
- if (revisions && Array.isArray(revisions)) {
418
- revisions.forEach((revision) => {
419
- if (revision.fields) {
420
- const fields = revision.fields;
421
- Object.keys(fields).forEach((fieldName) => {
422
- const fieldValue = fields[fieldName];
423
- // Check if this is an identity object by looking for common identity properties
424
- if (fieldValue &&
425
- typeof fieldValue === "object" &&
426
- !Array.isArray(fieldValue) &&
427
- "displayName" in fieldValue &&
428
- ("url" in fieldValue || "_links" in fieldValue || "uniqueName" in fieldValue)) {
429
- // Remove unwanted properties from identity objects
430
- delete fieldValue.url;
431
- delete fieldValue._links;
432
- delete fieldValue.id;
433
- delete fieldValue.uniqueName;
434
- delete fieldValue.imageUrl;
435
- delete fieldValue.descriptor;
436
- }
437
- });
438
- }
439
- });
440
- }
441
- return {
442
- content: [{ type: "text", text: JSON.stringify(revisions, null, 2) }],
443
- };
444
- }
445
- catch (error) {
446
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
447
- return {
448
- content: [{ type: "text", text: `Error listing work item revisions: ${errorMessage}` }],
449
- isError: true,
450
- };
451
- }
452
- });
453
- server.tool(WORKITEM_TOOLS.add_child_work_items, "Create one or many child work items from a parent by work item type and parent id. If a project is not specified, you will be prompted to select one.", {
454
- parentId: z.coerce.number().min(1).describe("The ID of the parent work item to create a child work item under."),
455
- 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."),
456
- workItemType: z.string().describe("The type of the child work item to create."),
457
- items: z.array(z.object({
458
- title: z.string().describe("The title of the child work item."),
459
- description: z.string().describe("The description of the child work item."),
460
- format: z.enum(["Markdown", "Html"]).default("Markdown").describe("Format for the description on the child work item, e.g., 'Markdown', 'Html'. Defaults to 'Markdown'."),
461
- areaPath: z.string().optional().describe("Optional area path for the child work item."),
462
- iterationPath: z.string().optional().describe("Optional iteration path for the child work item."),
463
- })),
464
- }, async ({ parentId, project, workItemType, items }) => {
465
- try {
466
- const connection = await connectionProvider();
467
- let resolvedProject = project;
468
- if (!resolvedProject) {
469
- const result = await elicitProject(server, connection, "Select the Azure DevOps project to create child work items in.");
470
- if ("response" in result)
471
- return result.response;
472
- resolvedProject = result.resolved;
473
- }
474
- const orgUrl = connection.serverUrl;
475
- const accessToken = await tokenProvider();
476
- if (items.length > 50) {
477
- return {
478
- content: [{ type: "text", text: `A maximum of 50 child work items can be created in a single call.` }],
479
- isError: true,
480
- };
481
- }
482
- const body = items.map((item, x) => {
483
- const encodedDescription = encodeFormattedValue(item.description, item.format);
484
- const ops = [
485
- {
486
- op: "add",
487
- path: "/id",
488
- value: `-${x + 1}`,
489
- },
490
- {
491
- op: "add",
492
- path: "/fields/System.Title",
493
- value: item.title,
494
- },
495
- {
496
- op: "add",
497
- path: "/fields/System.Description",
498
- value: encodedDescription,
499
- },
500
- {
501
- op: "add",
502
- path: "/fields/Microsoft.VSTS.TCM.ReproSteps",
503
- value: encodedDescription,
504
- },
505
- {
506
- op: "add",
507
- path: "/relations/-",
508
- value: {
509
- rel: "System.LinkTypes.Hierarchy-Reverse",
510
- url: `${connection.serverUrl}/${resolvedProject}/_apis/wit/workItems/${parentId}`,
511
- },
512
- },
513
- ];
514
- if (item.areaPath && item.areaPath.trim().length > 0) {
515
- ops.push({
516
- op: "add",
517
- path: "/fields/System.AreaPath",
518
- value: item.areaPath,
519
- });
520
- }
521
- if (item.iterationPath && item.iterationPath.trim().length > 0) {
522
- ops.push({
523
- op: "add",
524
- path: "/fields/System.IterationPath",
525
- value: item.iterationPath,
526
- });
527
- }
528
- if (item.format && item.format === "Markdown") {
529
- ops.push({
530
- op: "add",
531
- path: "/multilineFieldsFormat/System.Description",
532
- value: item.format,
533
- });
534
- ops.push({
535
- op: "add",
536
- path: "/multilineFieldsFormat/Microsoft.VSTS.TCM.ReproSteps",
537
- value: item.format,
538
- });
539
- }
540
- return {
541
- method: "PATCH",
542
- uri: `/${encodeURIComponent(resolvedProject)}/_apis/wit/workitems/$${encodeURIComponent(workItemType)}?api-version=${batchApiVersion}`,
543
- headers: {
544
- "Content-Type": "application/json-patch+json",
545
- },
546
- body: ops,
547
- };
548
- });
549
- const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, {
550
- method: "PATCH",
551
- headers: {
552
- "Authorization": `Bearer ${accessToken}`,
553
- "Content-Type": "application/json",
554
- "User-Agent": userAgentProvider(),
555
- },
556
- body: JSON.stringify(body),
557
- });
558
- if (!response.ok) {
559
- throw new Error(`Failed to update work items in batch: ${response.statusText}`);
560
- }
561
- const result = await response.json();
562
- return {
563
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
564
- };
565
- }
566
- catch (error) {
567
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
568
- return {
569
- content: [{ type: "text", text: `Error creating child work items: ${errorMessage}` }],
570
- isError: true,
571
- };
572
- }
573
- });
574
- server.tool(WORKITEM_TOOLS.link_work_item_to_pull_request, "Link a single work item to an existing pull request.", {
575
- projectId: z.string().describe("The project ID of the Azure DevOps project (note: project name is not valid)."),
576
- repositoryId: z.string().describe("The ID of the repository containing the pull request. Do not use the repository name here, use the ID instead."),
577
- pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request to link to."),
578
- workItemId: z.coerce.number().min(1).describe("The ID of the work item to link to the pull request."),
579
- 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)."),
580
- }, async ({ projectId, repositoryId, pullRequestId, workItemId, pullRequestProjectId }) => {
581
- try {
582
- const connection = await connectionProvider();
583
- const workItemTrackingApi = await connection.getWorkItemTrackingApi();
584
- // Create artifact link relation using vstfs format
585
- // Format: vstfs:///Git/PullRequestId/{project}/{repositoryId}/{pullRequestId}
586
- const artifactProjectId = pullRequestProjectId && pullRequestProjectId.trim() !== "" ? pullRequestProjectId : projectId;
587
- const artifactPathValue = `${artifactProjectId}/${repositoryId}/${pullRequestId}`;
588
- const vstfsUrl = `vstfs:///Git/PullRequestId/${encodeURIComponent(artifactPathValue)}`;
589
- // Use the PATCH document format for adding a relation
590
- const patchDocument = [
591
- {
592
- op: "add",
593
- path: "/relations/-",
594
- value: {
595
- rel: "ArtifactLink",
596
- url: vstfsUrl,
597
- attributes: {
598
- name: "Pull Request",
599
- },
600
- },
601
- },
602
- ];
603
- // Use the WorkItem API to update the work item with the new relation
604
- const workItem = await workItemTrackingApi.updateWorkItem({}, patchDocument, workItemId, projectId);
605
- if (!workItem) {
606
- return { content: [{ type: "text", text: "Work item update failed" }], isError: true };
607
- }
608
- return {
609
- content: [
610
- {
611
- type: "text",
612
- text: JSON.stringify({
613
- workItemId,
614
- pullRequestId,
615
- success: true,
616
- }, null, 2),
617
- },
618
- ],
619
- };
620
- }
621
- catch (error) {
622
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
623
- return {
624
- content: [{ type: "text", text: `Error linking work item to pull request: ${errorMessage}` }],
625
- isError: true,
626
- };
627
- }
628
- });
629
- server.tool(WORKITEM_TOOLS.get_work_items_for_iteration, "Retrieve a list of work items for a specified iteration. If a project is not specified, you will be prompted to select one.", {
630
- 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."),
631
- team: z.string().optional().describe("The name or ID of the Azure DevOps team. If not provided, the default team will be used."),
632
- iterationId: z.string().describe("The ID of the iteration to retrieve work items for."),
633
- }, async ({ project, team, iterationId }) => {
634
- try {
635
- const connection = await connectionProvider();
636
- let resolvedProject = project;
637
- if (!resolvedProject) {
638
- const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve work items for iteration.");
639
- if ("response" in result)
640
- return result.response;
641
- resolvedProject = result.resolved;
642
- }
643
- const workApi = await connection.getWorkApi();
644
- //get the work items for the current iteration
645
- const workItems = await workApi.getIterationWorkItems({ project: resolvedProject, team }, iterationId);
646
- return {
647
- content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }],
648
- };
649
- }
650
- catch (error) {
651
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
652
- return {
653
- content: [{ type: "text", text: `Error retrieving work items for iteration: ${errorMessage}` }],
654
- isError: true,
655
- };
656
- }
657
- });
658
- server.tool(WORKITEM_TOOLS.update_work_item, "Update a work item by ID with specified fields.", {
659
- id: z.coerce.number().min(1).describe("The ID of the work item to update."),
660
- updates: z
661
- .array(z.object({
662
- op: z
663
- .string()
664
- .transform((val) => val.toLowerCase())
665
- .pipe(z.enum(["add", "replace", "remove"]))
666
- .default("add")
667
- .describe("The operation to perform on the field."),
668
- path: z.string().describe("The path of the field to update, e.g., '/fields/System.Title'."),
669
- 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."),
670
- }))
671
- .describe("An array of field updates to apply to the work item."),
672
- }, async ({ id, updates }) => {
673
- try {
674
- const connection = await connectionProvider();
675
- const workItemApi = await connection.getWorkItemTrackingApi();
676
- // Convert operation names to lowercase for API
677
- const apiUpdates = updates.map((update) => ({
678
- ...update,
679
- op: update.op,
680
- }));
681
- const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id);
682
- return {
683
- content: [{ type: "text", text: JSON.stringify(updatedWorkItem, null, 2) }],
684
- };
685
- }
686
- catch (error) {
687
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
688
- return {
689
- content: [{ type: "text", text: `Error updating work item: ${errorMessage}` }],
690
- isError: true,
691
- };
692
- }
693
- });
694
- server.tool(WORKITEM_TOOLS.get_work_item_type, "Get a specific work item type. If a project is not specified, you will be prompted to select one.", {
695
- 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."),
696
- workItemType: z.string().describe("The name of the work item type to retrieve."),
697
- }, async ({ project, workItemType }) => {
698
- try {
699
- const connection = await connectionProvider();
700
- let resolvedProject = project;
701
- if (!resolvedProject) {
702
- const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve the work item type from.");
703
- if ("response" in result)
704
- return result.response;
705
- resolvedProject = result.resolved;
706
- }
707
- const workItemApi = await connection.getWorkItemTrackingApi();
708
- const workItemTypeInfo = await workItemApi.getWorkItemType(resolvedProject, workItemType);
709
- return {
710
- content: [{ type: "text", text: JSON.stringify(workItemTypeInfo, null, 2) }],
711
- };
712
- }
713
- catch (error) {
714
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
715
- return {
716
- content: [{ type: "text", text: `Error retrieving work item type: ${errorMessage}` }],
717
- isError: true,
718
- };
719
- }
720
- });
721
- server.tool(WORKITEM_TOOLS.create_work_item, "Create a new work item in a specified project and work item type. If a project is not specified, you will be prompted to select one.", {
722
- 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."),
723
- workItemType: z.string().describe("The type of work item to create, e.g., 'Task', 'Bug', etc."),
724
- fields: z
725
- .array(z.object({
726
- name: z.string().describe("The name of the field, e.g., 'System.Title'."),
727
- value: z.string().describe("The value of the field."),
728
- format: z.enum(["Html", "Markdown"]).optional().describe("the format of the field value, e.g., 'Html', 'Markdown'. Optional, defaults to 'Markdown'."),
729
- }))
730
- .describe("A record of field names and values to set on the new work item. Each fild is the field name and each value is the corresponding value to set for that field."),
731
- }, async ({ project, workItemType, fields }) => {
732
- try {
733
- const connection = await connectionProvider();
734
- let resolvedProject = project;
735
- if (!resolvedProject) {
736
- const result = await elicitProject(server, connection, "Select the Azure DevOps project to create the work item in.");
737
- if ("response" in result)
738
- return result.response;
739
- resolvedProject = result.resolved;
740
- }
741
- const workItemApi = await connection.getWorkItemTrackingApi();
742
- const document = fields.map(({ name, value, format }) => ({
743
- op: "add",
744
- path: `/fields/${name}`,
745
- value: encodeFormattedValue(value, format),
746
- }));
747
- // Check if any field has format === "Markdown" and add the multilineFieldsFormat operation
748
- // this should only happen for large text fields, but since we don't know by field name, lets assume if the users
749
- // passes a value longer than 100 characters, then we can set the format to Markdown
750
- fields.forEach(({ name, value, format }) => {
751
- if (value.length > 100 && format === "Markdown") {
752
- document.push({
753
- op: "add",
754
- path: `/multilineFieldsFormat/${name}`,
755
- value: "Markdown",
756
- });
757
- }
758
- });
759
- const newWorkItem = await workItemApi.createWorkItem(null, document, resolvedProject, workItemType);
760
- if (!newWorkItem) {
761
- return { content: [{ type: "text", text: "Work item was not created" }], isError: true };
762
- }
763
- return {
764
- content: [{ type: "text", text: JSON.stringify(newWorkItem, null, 2) }],
765
- };
766
- }
767
- catch (error) {
768
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
769
- return {
770
- content: [{ type: "text", text: `Error creating work item: ${errorMessage}` }],
771
- isError: true,
772
- };
773
- }
774
- });
775
- server.tool(WORKITEM_TOOLS.get_query, "Get a query by its ID or path. If a project is not specified, you will be prompted to select one.", {
776
- 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."),
777
- query: z.string().describe("The ID or path of the query to retrieve."),
65
+ id: z.coerce.number().min(1).optional().describe("Work item ID. Required for: get."),
66
+ ids: z.array(z.coerce.number().min(1)).optional().describe("Work item IDs. Required for: get_batch."),
67
+ workItemId: z.coerce.number().min(1).optional().describe("Work item ID. Required for: list_comments, list_revisions."),
68
+ 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."),
69
+ asOf: z.coerce.date().optional().describe("Retrieve the work item as of a specific date. Used for: get."),
778
70
  expand: z
779
- .enum(getEnumKeys(QueryExpand))
71
+ .enum(getEnumKeys(WorkItemExpand))
780
72
  .optional()
781
- .describe("Optional expand parameter to include additional details in the response. Defaults to 'None'."),
782
- depth: z.coerce.number().default(0).describe("Optional depth parameter to specify how deep to expand the query. Defaults to 0."),
783
- includeDeleted: z.boolean().default(false).describe("Whether to include deleted items in the query results. Defaults to false."),
784
- useIsoDateFormat: z.boolean().default(false).describe("Whether to use ISO date format in the response. Defaults to false."),
785
- }, async ({ project, query, expand, depth, includeDeleted, useIsoDateFormat }) => {
73
+ .describe("Expand options (None, Fields, Relations, Links, All). Used for: get, list_revisions. For get, cannot be combined with fields."),
74
+ top: z.coerce.number().optional().describe("Maximum number of results to return. Used for: list_comments, my, list_revisions. Defaults vary by action."),
75
+ includeCompleted: z.boolean().optional().default(false).describe("Include completed work items. Used for: my. Defaults to false."),
76
+ type: z.enum(["assignedtome", "myactivity"]).optional().describe("Type of work items to retrieve. Used for: my. Defaults to 'assignedtome'."),
77
+ skip: z.coerce.number().optional().describe("Number of results to skip for pagination. Used for: list_revisions."),
78
+ team: z.string().optional().describe("Team name or ID. Used for: list_for_iteration."),
79
+ iterationId: z.string().optional().describe("Iteration ID. Required for: list_for_iteration."),
80
+ workItemType: z.string().optional().describe("Work item type name. Required for: get_type."),
81
+ }, async ({ action, project, id, ids, workItemId, fields, asOf, expand, top, includeCompleted, type, skip, team, iterationId, workItemType }) => {
786
82
  try {
787
83
  const connection = await connectionProvider();
788
84
  let resolvedProject = project;
789
- if (!resolvedProject) {
790
- const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve the query from.");
791
- if ("response" in result)
792
- return result.response;
793
- resolvedProject = result.resolved;
794
- }
795
- const workItemApi = await connection.getWorkItemTrackingApi();
796
- const queryDetails = await workItemApi.getQuery(resolvedProject, query, safeEnumConvert(QueryExpand, expand), depth, includeDeleted, useIsoDateFormat);
797
- return {
798
- content: [{ type: "text", text: JSON.stringify(queryDetails, null, 2) }],
799
- };
800
- }
801
- catch (error) {
802
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
803
- return {
804
- content: [{ type: "text", text: `Error retrieving query: ${errorMessage}` }],
805
- isError: true,
806
- };
807
- }
808
- });
809
- server.tool(WORKITEM_TOOLS.get_query_results_by_id, "Retrieve the results of a work item query given the query ID. Supports full or IDs-only response types.", {
810
- id: z.string().describe("The ID of the query to retrieve results for."),
811
- project: z.string().optional().describe("The name or ID of the Azure DevOps project. If not provided, the default project will be used."),
812
- team: z.string().optional().describe("The name or ID of the Azure DevOps team. If not provided, the default team will be used."),
813
- timePrecision: z.boolean().optional().describe("Whether to include time precision in the results. Defaults to false."),
814
- top: z.coerce.number().default(50).describe("The maximum number of results to return. Defaults to 50."),
815
- responseType: z.enum(["full", "ids"]).default("full").describe("Response type: 'full' returns complete query results (default), 'ids' returns only work item IDs for reduced payload size."),
816
- }, async ({ id, project, team, timePrecision, top, responseType }) => {
817
- try {
818
- const connection = await connectionProvider();
819
- const workItemApi = await connection.getWorkItemTrackingApi();
820
- const teamContext = { project, team };
821
- const queryResult = await workItemApi.queryById(id, teamContext, timePrecision, top);
822
- // If ids mode, extract and return only the IDs
823
- if (responseType === "ids") {
824
- const ids = queryResult.workItems?.map((workItem) => workItem.id).filter((id) => id !== undefined) || [];
825
- return {
826
- content: [{ type: "text", text: JSON.stringify({ ids, count: ids.length }, null, 2) }],
827
- };
828
- }
829
- // Default: return full query results
830
- return {
831
- content: [{ type: "text", text: JSON.stringify(queryResult, null, 2) }],
832
- };
833
- }
834
- catch (error) {
835
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
836
- return {
837
- content: [{ type: "text", text: `Error retrieving query results: ${errorMessage}` }],
838
- isError: true,
839
- };
840
- }
841
- });
842
- server.tool(WORKITEM_TOOLS.update_work_items_batch, "Update work items in batch", {
843
- updates: z
844
- .array(z.object({
845
- op: z.enum(["Add", "Replace", "Remove"]).default("Add").describe("The operation to perform on the field."),
846
- id: z.coerce.number().min(1).describe("The ID of the work item to update."),
847
- path: z.string().describe("The path of the field to update, e.g., '/fields/System.Title'."),
848
- 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."),
849
- format: z
850
- .enum(["Html", "Markdown"])
851
- .optional()
852
- .describe("The format of the field value. Only to be used for large text fields. e.g., 'Html', 'Markdown'. Optional, defaults to 'Markdown'."),
853
- }))
854
- .describe("An array of updates to apply to work items. Each update should include the operation (op), work item ID (id), field path (path), and new value (value)."),
855
- }, async ({ updates }) => {
856
- try {
857
- const connection = await connectionProvider();
858
- const orgUrl = connection.serverUrl;
859
- const accessToken = await tokenProvider();
860
- // Extract unique IDs from the updates array
861
- const uniqueIds = Array.from(new Set(updates.map((update) => update.id)));
862
- const body = uniqueIds.map((id) => {
863
- const workItemUpdates = updates.filter((update) => update.id === id);
864
- const operations = workItemUpdates.map(({ op, path, value, format }) => ({
865
- op: op,
866
- path: path,
867
- value: encodeFormattedValue(value, format),
868
- }));
869
- // Add format operations for Markdown fields
870
- workItemUpdates.forEach(({ path, value, format }) => {
871
- if (format === "Markdown" && value && value.length > 100) {
872
- operations.push({
873
- op: "Add",
874
- path: `/multilineFieldsFormat${path.replace("/fields", "")}`,
875
- value: "Markdown",
876
- });
877
- }
878
- });
879
- return {
880
- method: "PATCH",
881
- uri: `/_apis/wit/workitems/${id}?api-version=${batchApiVersion}`,
882
- headers: {
883
- "Content-Type": "application/json-patch+json",
884
- },
885
- body: operations,
886
- };
887
- });
888
- const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, {
889
- method: "PATCH",
890
- headers: {
891
- "Authorization": `Bearer ${accessToken}`,
892
- "Content-Type": "application/json",
893
- "User-Agent": userAgentProvider(),
894
- },
895
- body: JSON.stringify(body),
896
- });
897
- if (!response.ok) {
898
- throw new Error(`Failed to update work items in batch: ${response.statusText}`);
85
+ if (action === "get") {
86
+ if (!id)
87
+ return { content: [{ type: "text", text: "id is required for get" }], isError: true };
88
+ if (!resolvedProject) {
89
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve the work item from.");
90
+ if ("response" in result)
91
+ return result.response;
92
+ resolvedProject = result.resolved;
93
+ }
94
+ let effectiveExpand = expand;
95
+ if (fields && fields.length > 0 && effectiveExpand != null) {
96
+ effectiveExpand = "none";
97
+ }
98
+ const workItemApi = await connection.getWorkItemTrackingApi();
99
+ const workItem = await workItemApi.getWorkItem(id, fields, asOf, effectiveExpand, resolvedProject);
100
+ return { content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }] };
899
101
  }
900
- const result = await response.json();
901
- return {
902
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
903
- };
904
- }
905
- catch (error) {
906
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
907
- return {
908
- content: [{ type: "text", text: `Error updating work items in batch: ${errorMessage}` }],
909
- isError: true,
910
- };
911
- }
912
- });
913
- server.tool(WORKITEM_TOOLS.work_items_link, "Link work items together in batch. If a project is not specified, you will be prompted to select one.", {
914
- 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."),
915
- updates: z
916
- .array(z.object({
917
- id: z.coerce.number().min(1).describe("The ID of the work item to update."),
918
- linkToId: z.coerce.number().min(1).describe("The ID of the work item to link to."),
919
- type: z
920
- .enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by"])
921
- .default("related")
922
- .describe("Type of link to create between the work items. Options include 'parent', 'child', 'duplicate', 'duplicate of', 'related', 'successor', 'predecessor', 'tested by', 'tests', 'affects', and 'affected by'. Defaults to 'related'."),
923
- comment: z.string().optional().describe("Optional comment to include with the link. This can be used to provide additional context for the link being created."),
924
- }))
925
- .describe(""),
926
- }, async ({ project, updates }) => {
927
- try {
928
- const connection = await connectionProvider();
929
- let resolvedProject = project;
930
- if (!resolvedProject) {
931
- const result = await elicitProject(server, connection, "Select the Azure DevOps project to link work items in.");
932
- if ("response" in result)
933
- return result.response;
934
- resolvedProject = result.resolved;
102
+ if (action === "get_batch") {
103
+ if (!ids || ids.length === 0)
104
+ return { content: [{ type: "text", text: "ids is required for get_batch" }], isError: true };
105
+ if (!resolvedProject) {
106
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve work items for.");
107
+ if ("response" in result)
108
+ return result.response;
109
+ resolvedProject = result.resolved;
110
+ }
111
+ const workItemApi = await connection.getWorkItemTrackingApi();
112
+ const defaultFields = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.Parent", "System.Tags", "Microsoft.VSTS.Common.StackRank", "System.AssignedTo"];
113
+ const fieldsToUse = !fields || fields.length === 0 ? defaultFields : fields;
114
+ const workitems = await workItemApi.getWorkItemsBatch({ ids, fields: fieldsToUse }, resolvedProject);
115
+ const identityFields = [
116
+ "System.AssignedTo",
117
+ "System.CreatedBy",
118
+ "System.ChangedBy",
119
+ "System.AuthorizedAs",
120
+ "Microsoft.VSTS.Common.ActivatedBy",
121
+ "Microsoft.VSTS.Common.ResolvedBy",
122
+ "Microsoft.VSTS.Common.ClosedBy",
123
+ ];
124
+ if (workitems && Array.isArray(workitems)) {
125
+ workitems.forEach((item) => {
126
+ if (item.fields) {
127
+ identityFields.forEach((fieldName) => {
128
+ if (item.fields && item.fields[fieldName] && typeof item.fields[fieldName] === "object") {
129
+ const identityField = item.fields[fieldName];
130
+ const name = identityField.displayName || "";
131
+ const email = identityField.uniqueName || "";
132
+ item.fields[fieldName] = `${name} <${email}>`.trim();
133
+ }
134
+ });
135
+ }
136
+ });
137
+ }
138
+ return { content: [{ type: "text", text: JSON.stringify(workitems, null, 2) }] };
935
139
  }
936
- const orgUrl = connection.serverUrl;
937
- const accessToken = await tokenProvider();
938
- // Extract unique IDs from the updates array
939
- const uniqueIds = Array.from(new Set(updates.map((update) => update.id)));
940
- const body = uniqueIds.map((id) => ({
941
- method: "PATCH",
942
- uri: `/_apis/wit/workitems/${id}?api-version=${batchApiVersion}`,
943
- headers: {
944
- "Content-Type": "application/json-patch+json",
945
- },
946
- body: updates
947
- .filter((update) => update.id === id)
948
- .map(({ linkToId, type, comment }) => ({
949
- op: "add",
950
- path: "/relations/-",
951
- value: {
952
- rel: `${getLinkTypeFromName(type)}`,
953
- url: `${orgUrl}/${resolvedProject}/_apis/wit/workItems/${linkToId}`,
954
- attributes: {
955
- comment: comment || "",
956
- },
957
- },
958
- })),
959
- }));
960
- const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, {
961
- method: "PATCH",
962
- headers: {
963
- "Authorization": `Bearer ${accessToken}`,
964
- "Content-Type": "application/json",
965
- "User-Agent": userAgentProvider(),
966
- },
967
- body: JSON.stringify(body),
968
- });
969
- if (!response.ok) {
970
- throw new Error(`Failed to update work items in batch: ${response.statusText}`);
140
+ if (action === "list_comments") {
141
+ if (!workItemId)
142
+ return { content: [{ type: "text", text: "workItemId is required for list_comments" }], isError: true };
143
+ if (!resolvedProject) {
144
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to list work item comments for.");
145
+ if ("response" in result)
146
+ return result.response;
147
+ resolvedProject = result.resolved;
148
+ }
149
+ const workItemApi = await connection.getWorkItemTrackingApi();
150
+ const comments = await workItemApi.getComments(resolvedProject, workItemId, top ?? 50);
151
+ return { content: [{ type: "text", text: JSON.stringify(comments, null, 2) }] };
971
152
  }
972
- const result = await response.json();
973
- return {
974
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
975
- };
976
- }
977
- catch (error) {
978
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
979
- return {
980
- content: [{ type: "text", text: `Error linking work items: ${errorMessage}` }],
981
- isError: true,
982
- };
983
- }
984
- });
985
- server.tool(WORKITEM_TOOLS.work_item_unlink, "Remove one or many links from a single work item. If a project is not specified, you will be prompted to select one.", {
986
- 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."),
987
- id: z.coerce.number().min(1).describe("The ID of the work item to remove the links from."),
988
- type: z
989
- .enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by", "artifact"])
990
- .default("related")
991
- .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'."),
992
- 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."),
993
- }, async ({ project, id, type, url }) => {
994
- try {
995
- const connection = await connectionProvider();
996
- let resolvedProject = project;
997
- if (!resolvedProject) {
998
- const result = await elicitProject(server, connection, "Select the Azure DevOps project to unlink work items in.");
999
- if ("response" in result)
1000
- return result.response;
1001
- resolvedProject = result.resolved;
153
+ if (action === "my") {
154
+ if (!resolvedProject) {
155
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve work items for.");
156
+ if ("response" in result)
157
+ return result.response;
158
+ resolvedProject = result.resolved;
159
+ }
160
+ const workApi = await connection.getWorkApi();
161
+ const workItems = await workApi.getPredefinedQueryResults(resolvedProject, type ?? "assignedtome", top ?? 50, includeCompleted ?? false);
162
+ return { content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }] };
1002
163
  }
1003
- const workItemApi = await connection.getWorkItemTrackingApi();
1004
- const workItem = await workItemApi.getWorkItem(id, undefined, undefined, WorkItemExpand.Relations, resolvedProject);
1005
- const relations = workItem.relations ?? [];
1006
- const linkType = getLinkTypeFromName(type);
1007
- let relationIndexes = [];
1008
- if (url && url.trim().length > 0) {
1009
- // If url is provided, find relations matching both rel type and url
1010
- relationIndexes = relations.map((relation, idx) => (relation.rel === linkType && relation.url === url ? idx : -1)).filter((idx) => idx !== -1);
164
+ if (action === "list_revisions") {
165
+ if (!workItemId)
166
+ return { content: [{ type: "text", text: "workItemId is required for list_revisions" }], isError: true };
167
+ if (!resolvedProject) {
168
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to list work item revisions for.");
169
+ if ("response" in result)
170
+ return result.response;
171
+ resolvedProject = result.resolved;
172
+ }
173
+ const workItemApi = await connection.getWorkItemTrackingApi();
174
+ const revisions = await workItemApi.getRevisions(workItemId, top ?? 50, skip, safeEnumConvert(WorkItemExpand, expand), resolvedProject);
175
+ if (revisions && Array.isArray(revisions)) {
176
+ revisions.forEach((revision) => {
177
+ if (revision.fields) {
178
+ const revFields = revision.fields;
179
+ Object.keys(revFields).forEach((fieldName) => {
180
+ const fieldValue = revFields[fieldName];
181
+ if (fieldValue &&
182
+ typeof fieldValue === "object" &&
183
+ !Array.isArray(fieldValue) &&
184
+ "displayName" in fieldValue &&
185
+ ("url" in fieldValue || "_links" in fieldValue || "uniqueName" in fieldValue)) {
186
+ delete fieldValue.url;
187
+ delete fieldValue._links;
188
+ delete fieldValue.id;
189
+ delete fieldValue.uniqueName;
190
+ delete fieldValue.imageUrl;
191
+ delete fieldValue.descriptor;
192
+ }
193
+ });
194
+ }
195
+ });
196
+ }
197
+ return { content: [{ type: "text", text: JSON.stringify(revisions, null, 2) }] };
1011
198
  }
1012
- else {
1013
- // If url is not provided, find all relations matching rel type
1014
- relationIndexes = relations.map((relation, idx) => (relation.rel === linkType ? idx : -1)).filter((idx) => idx !== -1);
199
+ if (action === "list_for_iteration") {
200
+ if (!iterationId)
201
+ return { content: [{ type: "text", text: "iterationId is required for list_for_iteration" }], isError: true };
202
+ if (!resolvedProject) {
203
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve work items for iteration.");
204
+ if ("response" in result)
205
+ return result.response;
206
+ resolvedProject = result.resolved;
207
+ }
208
+ const workApi = await connection.getWorkApi();
209
+ const workItems = await workApi.getIterationWorkItems({ project: resolvedProject, team }, iterationId);
210
+ return { content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }] };
1015
211
  }
1016
- if (relationIndexes.length === 0) {
1017
- return {
1018
- content: [{ type: "text", text: `No matching relations found for link type '${type}'${url ? ` and URL '${url}'` : ""}.\n${JSON.stringify(relations, null, 2)}` }],
1019
- isError: true,
1020
- };
212
+ if (action === "get_type") {
213
+ if (!workItemType)
214
+ return { content: [{ type: "text", text: "workItemType is required for get_type" }], isError: true };
215
+ if (!resolvedProject) {
216
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve the work item type from.");
217
+ if ("response" in result)
218
+ return result.response;
219
+ resolvedProject = result.resolved;
220
+ }
221
+ const workItemApi = await connection.getWorkItemTrackingApi();
222
+ const workItemTypeInfo = await workItemApi.getWorkItemType(resolvedProject, workItemType);
223
+ return { content: [{ type: "text", text: JSON.stringify(workItemTypeInfo, null, 2) }] };
1021
224
  }
1022
- // Get the relations that will be removed for logging
1023
- const removedRelations = relationIndexes.map((idx) => relations[idx]);
1024
- // Sort indexes in descending order to avoid index shifting when removing
1025
- relationIndexes.sort((a, b) => b - a);
1026
- const apiUpdates = relationIndexes.map((idx) => ({
1027
- op: "remove",
1028
- path: `/relations/${idx}`,
1029
- }));
1030
- const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id, resolvedProject);
1031
- return {
1032
- content: [
1033
- {
1034
- type: "text",
1035
- text: `Removed ${removedRelations.length} link(s) of type '${type}':\n` +
1036
- JSON.stringify(removedRelations, null, 2) +
1037
- `\n\nUpdated work item result:\n` +
1038
- JSON.stringify(updatedWorkItem, null, 2),
1039
- },
1040
- ],
1041
- isError: false,
1042
- };
225
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
1043
226
  }
1044
227
  catch (error) {
1045
- return {
1046
- content: [
1047
- {
1048
- type: "text",
1049
- text: `Error unlinking work item: ${error instanceof Error ? error.message : "Unknown error occurred"}`,
1050
- },
1051
- ],
1052
- isError: true,
1053
- };
1054
- }
1055
- });
1056
- server.tool(WORKITEM_TOOLS.add_artifact_link, "Add artifact links (repository, branch, commit, builds) to work items. You can either provide the full vstfs URI or the individual components to build it automatically. If a project is not specified, you will be prompted to select one.", {
1057
- workItemId: z.coerce.number().min(1).describe("The ID of the work item to add the artifact link to."),
1058
- 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."),
1059
- // Option 1: Provide full URI directly
1060
- artifactUri: z.string().optional().describe("The complete VSTFS URI of the artifact to link. If provided, individual component parameters are ignored."),
1061
- // Option 2: Provide individual components to build URI automatically based on linkType
1062
- projectId: z.string().optional().describe("The project ID (GUID) containing the artifact. Required for Git artifacts when artifactUri is not provided."),
1063
- repositoryId: z.string().optional().describe("The repository ID (GUID) containing the artifact. Required for Git artifacts when artifactUri is not provided."),
1064
- branchName: z.string().optional().describe("The branch name (e.g., 'main'). Required when linkType is 'Branch'."),
1065
- commitId: z.string().optional().describe("The commit SHA hash. Required when linkType is 'Fixed in Commit'."),
1066
- pullRequestId: z.coerce.number().min(1).optional().describe("The pull request ID. Required when linkType is 'Pull Request'."),
1067
- buildId: z.coerce.number().min(1).optional().describe("The build ID. Required when linkType is 'Build', 'Found in build', or 'Integrated in build'."),
1068
- wikiId: z.string().optional().describe("The wiki ID (GUID). Required when linkType is 'Wiki'."),
1069
- pageId: z.coerce
1070
- .number()
1071
- .min(1)
1072
- .optional()
1073
- .describe("The numeric wiki page ID from the browser URL (e.g., '98' in '.../wikis/Contoso.wiki/98/What-is-Contoso'). When provided for 'Wiki' links, the full page path is resolved automatically via the API. Takes precedence over 'pagePath'."),
1074
- pagePath: z
1075
- .string()
1076
- .optional()
1077
- .describe("The full wiki page path from the wiki root (e.g., '/Home/What-is-Contoso'). Required when linkType is 'Wiki' and 'pageId' is not provided. Must be the complete path, not just the page name from the URL."),
1078
- linkType: z
1079
- .enum([
1080
- "Branch",
1081
- "Build",
1082
- "Fixed in Changeset",
1083
- "Fixed in Commit",
1084
- "Found in build",
1085
- "Integrated in build",
1086
- "Model Link",
1087
- "Pull Request",
1088
- "Related Workitem",
1089
- "Result Attachment",
1090
- "Source Code File",
1091
- "Tag",
1092
- "Test Result",
1093
- "Wiki",
1094
- ])
1095
- .default("Branch")
1096
- .describe("Type of artifact link, defaults to 'Branch'. This determines both the link type and how to build the VSTFS URI from individual components."),
1097
- comment: z.string().optional().describe("Comment to include with the artifact link."),
1098
- }, async ({ workItemId, project, artifactUri, projectId, repositoryId, branchName, commitId, pullRequestId, buildId, wikiId, pageId, pagePath, linkType, comment }) => {
228
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
229
+ const msgs = {
230
+ get: `Error retrieving work item: ${errorMessage}`,
231
+ get_batch: `Error retrieving work items batch: ${errorMessage}`,
232
+ list_comments: `Error listing work item comments: ${errorMessage}`,
233
+ my: `Error retrieving work items: ${errorMessage}`,
234
+ list_revisions: `Error listing work item revisions: ${errorMessage}`,
235
+ list_for_iteration: `Error retrieving work items for iteration: ${errorMessage}`,
236
+ get_type: `Error retrieving work item type: ${errorMessage}`,
237
+ };
238
+ return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true };
239
+ }
240
+ });
241
+ // --- wit_query --------------------------------------------------------------
242
+ server.tool(WORKITEM_TOOLS.wit_query, "Retrieve work item query data for a project. Use the action parameter to specify the operation.", {
243
+ action: z
244
+ .enum(["get", "get_results", "wiql"])
245
+ .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)."),
246
+ 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."),
247
+ query: z.string().optional().describe("The ID or path of the query. Required for: get."),
248
+ expand: z
249
+ .enum(getEnumKeys(QueryExpand))
250
+ .optional()
251
+ .describe("Expand parameter to include additional details. Used for: get."),
252
+ depth: z.coerce.number().default(0).describe("Depth of expansion. Used for: get. Defaults to 0."),
253
+ includeDeleted: z.boolean().default(false).describe("Include deleted items. Used for: get. Defaults to false."),
254
+ useIsoDateFormat: z.boolean().default(false).describe("Use ISO date format in the response. Used for: get. Defaults to false."),
255
+ id: z.string().optional().describe("The ID of the saved query. Required for: get_results."),
256
+ team: z.string().optional().describe("Team name or ID. Used for: get_results, wiql."),
257
+ timePrecision: z.boolean().optional().describe("Include time precision in date fields. Used for: get_results, wiql."),
258
+ top: z.coerce.number().default(50).describe("Maximum number of results to return. Used for: get_results, wiql. Defaults to 50."),
259
+ 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."),
260
+ 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".'),
261
+ }, async ({ action, project, query, expand, depth, includeDeleted, useIsoDateFormat, id, team, timePrecision, top, responseType, wiql }) => {
1099
262
  try {
1100
263
  const connection = await connectionProvider();
1101
264
  let resolvedProject = project;
1102
265
  if (!resolvedProject) {
1103
- const result = await elicitProject(server, connection, "Select the Azure DevOps project to add the artifact link in.");
266
+ const result = await elicitProject(server, connection, `Select the Azure DevOps project for ${action}.`);
1104
267
  if ("response" in result)
1105
268
  return result.response;
1106
269
  resolvedProject = result.resolved;
1107
270
  }
1108
- const workItemTrackingApi = await connection.getWorkItemTrackingApi();
1109
- let finalArtifactUri;
1110
- if (artifactUri) {
1111
- // Use the provided full URI
1112
- finalArtifactUri = artifactUri;
271
+ if (action === "get") {
272
+ if (!query)
273
+ return { content: [{ type: "text", text: "query is required for get" }], isError: true };
274
+ const workItemApi = await connection.getWorkItemTrackingApi();
275
+ const queryDetails = await workItemApi.getQuery(resolvedProject, query, safeEnumConvert(QueryExpand, expand), depth, includeDeleted, useIsoDateFormat);
276
+ return { content: [{ type: "text", text: JSON.stringify(queryDetails, null, 2) }] };
1113
277
  }
1114
- else {
1115
- // Build the URI from individual components based on linkType
1116
- switch (linkType) {
1117
- case "Branch":
1118
- if (!projectId || !repositoryId || !branchName) {
1119
- return {
1120
- content: [{ type: "text", text: "For 'Branch' links, 'projectId', 'repositoryId', and 'branchName' are required." }],
1121
- isError: true,
1122
- };
1123
- }
1124
- finalArtifactUri = `vstfs:///Git/Ref/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2FGB${encodeURIComponent(branchName)}`;
1125
- break;
1126
- case "Fixed in Commit":
1127
- if (!projectId || !repositoryId || !commitId) {
1128
- return {
1129
- content: [{ type: "text", text: "For 'Fixed in Commit' links, 'projectId', 'repositoryId', and 'commitId' are required." }],
1130
- isError: true,
1131
- };
1132
- }
1133
- finalArtifactUri = `vstfs:///Git/Commit/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2F${encodeURIComponent(commitId)}`;
1134
- break;
1135
- case "Pull Request":
1136
- if (!projectId || !repositoryId || pullRequestId === undefined) {
1137
- return {
1138
- content: [{ type: "text", text: "For 'Pull Request' links, 'projectId', 'repositoryId', and 'pullRequestId' are required." }],
1139
- isError: true,
1140
- };
1141
- }
1142
- finalArtifactUri = `vstfs:///Git/PullRequestId/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2F${encodeURIComponent(pullRequestId.toString())}`;
1143
- break;
1144
- case "Build":
1145
- case "Found in build":
1146
- case "Integrated in build":
1147
- if (buildId === undefined) {
1148
- return {
1149
- content: [{ type: "text", text: `For '${linkType}' links, 'buildId' is required.` }],
1150
- isError: true,
1151
- };
1152
- }
1153
- finalArtifactUri = `vstfs:///Build/Build/${encodeURIComponent(buildId.toString())}`;
1154
- break;
1155
- case "Wiki": {
1156
- if (!projectId || !wikiId) {
1157
- return {
1158
- content: [{ type: "text", text: "For 'Wiki' links, 'projectId', 'wikiId', and 'pagePath' are required." }],
1159
- isError: true,
1160
- };
1161
- }
1162
- let resolvedPagePath = pagePath;
1163
- if (pageId !== undefined) {
1164
- // Look up the actual page path by page ID to get the full path
1165
- const orgUrl = connection.serverUrl;
1166
- const accessToken = await tokenProvider();
1167
- const pageResponse = await fetch(`${orgUrl}/${encodeURIComponent(resolvedProject)}/_apis/wiki/wikis/${encodeURIComponent(wikiId)}/pages/${pageId}?api-version=7.1`, {
1168
- headers: {
1169
- "Authorization": `Bearer ${accessToken}`,
1170
- "User-Agent": userAgentProvider(),
1171
- },
1172
- });
1173
- if (!pageResponse.ok) {
1174
- return {
1175
- content: [{ type: "text", text: `Failed to look up wiki page ID ${pageId}: ${pageResponse.statusText}` }],
1176
- isError: true,
1177
- };
1178
- }
1179
- const pageData = await pageResponse.json();
1180
- resolvedPagePath = pageData.path;
1181
- }
1182
- if (!resolvedPagePath) {
1183
- return {
1184
- content: [{ type: "text", text: "For 'Wiki' links, 'pageId' or 'pagePath' is required." }],
1185
- isError: true,
1186
- };
1187
- }
1188
- // Strip leading slash, then encode each segment joined by %2F
1189
- const normalizedPath = resolvedPagePath.startsWith("/") ? resolvedPagePath.slice(1) : resolvedPagePath;
1190
- const encodedPath = normalizedPath.split("/").map(encodeURIComponent).join("%2F");
1191
- finalArtifactUri = `vstfs:///Wiki/WikiPage/${encodeURIComponent(projectId)}%2F${encodeURIComponent(wikiId)}%2F${encodedPath}`;
1192
- break;
1193
- }
1194
- default:
1195
- return {
1196
- content: [{ type: "text", text: `URI building from components is not supported for link type '${linkType}'. Please provide the full 'artifactUri' instead.` }],
1197
- isError: true,
1198
- };
278
+ if (action === "get_results") {
279
+ if (!id)
280
+ return { content: [{ type: "text", text: "id is required for get_results" }], isError: true };
281
+ const workItemApi = await connection.getWorkItemTrackingApi();
282
+ const teamContext = { project: resolvedProject, team };
283
+ const queryResult = await workItemApi.queryById(id, teamContext, timePrecision, top);
284
+ if (responseType === "ids") {
285
+ const ids = queryResult.workItems?.map((workItem) => workItem.id).filter((wid) => wid !== undefined) || [];
286
+ return { content: [{ type: "text", text: JSON.stringify({ ids, count: ids.length }, null, 2) }] };
1199
287
  }
288
+ return { content: [{ type: "text", text: JSON.stringify(queryResult, null, 2) }] };
1200
289
  }
1201
- // Create the patch document for adding an artifact link relation
1202
- const patchDocument = [
1203
- {
1204
- op: "add",
1205
- path: "/relations/-",
1206
- value: {
1207
- rel: "ArtifactLink",
1208
- url: finalArtifactUri,
1209
- attributes: {
1210
- name: getArtifactLinkAttributeName(linkType),
1211
- ...(comment && { comment }),
1212
- },
1213
- },
1214
- },
1215
- ];
1216
- // Use the WorkItem API to update the work item with the new relation
1217
- const workItem = await workItemTrackingApi.updateWorkItem({}, patchDocument, workItemId, resolvedProject);
1218
- if (!workItem) {
1219
- return { content: [{ type: "text", text: "Work item update failed" }], isError: true };
290
+ if (action === "wiql") {
291
+ if (!wiql)
292
+ return { content: [{ type: "text", text: "wiql is required for wiql" }], isError: true };
293
+ const workItemApi = await connection.getWorkItemTrackingApi();
294
+ const teamContext = { project: resolvedProject, team };
295
+ const queryResult = await workItemApi.queryByWiql({ query: wiql }, teamContext, timePrecision, top);
296
+ return createExternalContentResponse(queryResult, "wiql query results");
1220
297
  }
1221
- return {
1222
- content: [
1223
- {
1224
- type: "text",
1225
- text: JSON.stringify({
1226
- workItemId,
1227
- artifactUri: finalArtifactUri,
1228
- linkType,
1229
- comment: comment || null,
1230
- success: true,
1231
- }, null, 2),
1232
- },
1233
- ],
1234
- };
298
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
1235
299
  }
1236
300
  catch (error) {
1237
301
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
1238
- return {
1239
- content: [{ type: "text", text: `Error adding artifact link to work item: ${errorMessage}` }],
1240
- isError: true,
302
+ const msgs = {
303
+ get: `Error retrieving query: ${errorMessage}`,
304
+ get_results: `Error retrieving query results: ${errorMessage}`,
305
+ wiql: `Error executing WIQL query: ${errorMessage}`,
1241
306
  };
307
+ return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true };
1242
308
  }
1243
309
  });
1244
- server.tool(WORKITEM_TOOLS.query_by_wiql, "Execute a WIQL (Work Item Query Language) query and return the matching work items. If a project is not specified, you will be prompted to select one.", {
1245
- wiql: z.string().max(32768).describe('The WIQL query string to execute, e.g., "SELECT [System.Id], [System.Title] FROM WorkItems WHERE [System.TeamProject] = @project"'),
310
+ // --- wit_backlog ------------------------------------------------------------
311
+ server.tool(WORKITEM_TOOLS.wit_backlog, "Retrieve backlog data for a project and team. Use the action parameter to specify the operation.", {
312
+ action: z.enum(["list", "list_work_items"]).describe("The action to perform. Options: list (list backlog levels for a team), list_work_items (list work items in a specific backlog level)."),
1246
313
  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."),
1247
- team: z.string().optional().describe("The name or ID of the Azure DevOps team. If not provided, the default team context will be used."),
1248
- timePrecision: z.boolean().optional().describe("Whether to include time precision in date fields. Defaults to false."),
1249
- top: z.coerce.number().default(50).describe("The maximum number of results to return. Defaults to 50."),
1250
- }, async ({ wiql, project, team, timePrecision, top }) => {
314
+ 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."),
315
+ backlogId: z.string().optional().describe("The ID of the backlog category to retrieve work items from. Required for: list_work_items."),
316
+ }, async ({ action, project, team, backlogId }) => {
1251
317
  try {
1252
318
  const connection = await connectionProvider();
1253
319
  let resolvedProject = project;
1254
320
  if (!resolvedProject) {
1255
- const result = await elicitProject(server, connection, "Select the Azure DevOps project to run the WIQL query against.");
321
+ const label = action === "list" ? "list backlogs" : "list backlog work items";
322
+ const result = await elicitProject(server, connection, `Select the Azure DevOps project to ${label} for.`);
1256
323
  if ("response" in result)
1257
324
  return result.response;
1258
325
  resolvedProject = result.resolved;
1259
326
  }
1260
- const workItemApi = await connection.getWorkItemTrackingApi();
1261
- const teamContext = { project: resolvedProject, team };
1262
- const queryResult = await workItemApi.queryByWiql({ query: wiql }, teamContext, timePrecision, top);
1263
- return createExternalContentResponse(queryResult, "wiql query results");
327
+ let resolvedTeam = team;
328
+ if (!resolvedTeam) {
329
+ const label = action === "list" ? "list backlogs" : "list backlog work items";
330
+ const result = await elicitTeam(server, connection, resolvedProject, `Select the Azure DevOps team to ${label} for.`);
331
+ if ("response" in result)
332
+ return result.response;
333
+ resolvedTeam = result.resolved;
334
+ }
335
+ const workApi = await connection.getWorkApi();
336
+ const teamContext = { project: resolvedProject, team: resolvedTeam };
337
+ if (action === "list") {
338
+ const backlogs = await workApi.getBacklogs(teamContext);
339
+ return { content: [{ type: "text", text: JSON.stringify(backlogs, null, 2) }] };
340
+ }
341
+ if (action === "list_work_items") {
342
+ if (!backlogId)
343
+ return { content: [{ type: "text", text: "backlogId is required for list_work_items" }], isError: true };
344
+ const workItems = await workApi.getBacklogLevelWorkItems(teamContext, backlogId);
345
+ return { content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }] };
346
+ }
347
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
1264
348
  }
1265
349
  catch (error) {
1266
350
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
1267
- return {
1268
- content: [{ type: "text", text: `Error executing WIQL query: ${errorMessage}` }],
1269
- isError: true,
351
+ const msgs = {
352
+ list: `Error listing backlogs: ${errorMessage}`,
353
+ list_work_items: `Error listing backlog work items: ${errorMessage}`,
1270
354
  };
355
+ return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true };
1271
356
  }
1272
357
  });
1273
- server.tool(WORKITEM_TOOLS.get_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.", {
358
+ // --- wit_work_item_attachment -----------------------------------------------
359
+ 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.", {
1274
360
  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."),
1275
361
  attachmentId: z.string().describe("The GUID of the attachment. Found in the attachment URL: https://dev.azure.com/{org}/{project}/_apis/wit/attachments/{attachmentId}"),
1276
362
  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."),
@@ -1344,6 +430,597 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
1344
430
  };
1345
431
  }
1346
432
  });
433
+ // --- wit_work_item_write ----------------------------------------------------
434
+ server.tool(WORKITEM_TOOLS.wit_work_item_write, "Write operations for work items. Use the action parameter to specify the operation.", {
435
+ action: z
436
+ .enum(["create", "update", "update_batch", "add_child"])
437
+ .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)."),
438
+ 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."),
439
+ id: z.coerce.number().min(1).optional().describe("Work item ID to update. Required for: update."),
440
+ workItemType: z.string().optional().describe("The type of work item. Required for: create, add_child."),
441
+ fields: z
442
+ .array(z.object({
443
+ name: z.string().describe("The field name, e.g. 'System.Title'."),
444
+ value: z.string().describe("The field value."),
445
+ format: z.enum(["Html", "Markdown"]).optional().describe("Format for large text fields. Optional."),
446
+ }))
447
+ .optional()
448
+ .describe("Field values to set on the work item. Required for: create."),
449
+ updates: z
450
+ .array(z.object({
451
+ op: z
452
+ .string()
453
+ .transform((val) => val.toLowerCase())
454
+ .pipe(z.enum(["add", "replace", "remove"]))
455
+ .default("add")
456
+ .describe("The operation to perform."),
457
+ path: z.string().describe("The field path, e.g. '/fields/System.Title'."),
458
+ value: z.string().describe("The new value for the field."),
459
+ }))
460
+ .optional()
461
+ .describe("Field updates for a single work item. Required for: update."),
462
+ batchUpdates: z
463
+ .array(z.object({
464
+ op: z.enum(["Add", "Replace", "Remove"]).default("Add").describe("The operation to perform."),
465
+ id: z.coerce.number().min(1).describe("The work item ID to update."),
466
+ path: z.string().describe("The field path, e.g. '/fields/System.Title'."),
467
+ value: z.string().describe("The new value for the field."),
468
+ format: z.enum(["Html", "Markdown"]).optional().describe("Format for large text fields. Optional."),
469
+ }))
470
+ .optional()
471
+ .describe("Updates for multiple work items. Required for: update_batch."),
472
+ parentId: z.coerce.number().min(1).optional().describe("The ID of the parent work item. Required for: add_child."),
473
+ items: z
474
+ .array(z.object({
475
+ title: z.string().describe("The title of the child work item."),
476
+ description: z.string().describe("The description of the child work item."),
477
+ format: z.enum(["Markdown", "Html"]).default("Markdown").describe("Format for the description. Defaults to 'Markdown'."),
478
+ areaPath: z.string().optional().describe("Optional area path for the child work item."),
479
+ iterationPath: z.string().optional().describe("Optional iteration path for the child work item."),
480
+ }))
481
+ .optional()
482
+ .describe("Child work items to create. Required for: add_child."),
483
+ }, async ({ action, project, id, workItemType, fields, updates, batchUpdates, parentId, items }) => {
484
+ try {
485
+ const connection = await connectionProvider();
486
+ let resolvedProject = project;
487
+ if (action === "create") {
488
+ if (!workItemType)
489
+ return { content: [{ type: "text", text: "workItemType is required for create" }], isError: true };
490
+ if (!fields || fields.length === 0)
491
+ return { content: [{ type: "text", text: "fields is required for create" }], isError: true };
492
+ if (!resolvedProject) {
493
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to create the work item in.");
494
+ if ("response" in result)
495
+ return result.response;
496
+ resolvedProject = result.resolved;
497
+ }
498
+ const workItemApi = await connection.getWorkItemTrackingApi();
499
+ const document = fields.map(({ name, value, format }) => ({
500
+ op: "add",
501
+ path: `/fields/${name}`,
502
+ value: encodeFormattedValue(value, format),
503
+ }));
504
+ fields.forEach(({ name, format }) => {
505
+ if (format === "Markdown") {
506
+ document.push({
507
+ op: "add",
508
+ path: `/multilineFieldsFormat/${name}`,
509
+ value: "Markdown",
510
+ });
511
+ }
512
+ });
513
+ const newWorkItem = await workItemApi.createWorkItem(null, document, resolvedProject, workItemType);
514
+ if (!newWorkItem) {
515
+ return { content: [{ type: "text", text: "Work item was not created" }], isError: true };
516
+ }
517
+ return { content: [{ type: "text", text: JSON.stringify(newWorkItem, null, 2) }] };
518
+ }
519
+ if (action === "update") {
520
+ if (!id)
521
+ return { content: [{ type: "text", text: "id is required for update" }], isError: true };
522
+ if (!updates || updates.length === 0)
523
+ return { content: [{ type: "text", text: "updates is required for update" }], isError: true };
524
+ const workItemApi = await connection.getWorkItemTrackingApi();
525
+ const apiUpdates = updates.map((update) => ({ ...update, op: update.op }));
526
+ const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id);
527
+ return { content: [{ type: "text", text: JSON.stringify(updatedWorkItem, null, 2) }] };
528
+ }
529
+ if (action === "update_batch") {
530
+ if (!batchUpdates || batchUpdates.length === 0)
531
+ return { content: [{ type: "text", text: "batchUpdates is required for update_batch" }], isError: true };
532
+ const orgUrl = connection.serverUrl;
533
+ const accessToken = await tokenProvider();
534
+ const uniqueIds = Array.from(new Set(batchUpdates.map((update) => update.id)));
535
+ const body = uniqueIds.map((uid) => {
536
+ const workItemUpdates = batchUpdates.filter((update) => update.id === uid);
537
+ const operations = workItemUpdates.map(({ op, path: fieldPath, value, format }) => ({
538
+ op: op,
539
+ path: fieldPath,
540
+ value: encodeFormattedValue(value, format),
541
+ }));
542
+ workItemUpdates.forEach(({ path: fieldPath, format }) => {
543
+ if (format === "Markdown") {
544
+ operations.push({
545
+ op: "Add",
546
+ path: `/multilineFieldsFormat${fieldPath.replace("/fields", "")}`,
547
+ value: "Markdown",
548
+ });
549
+ }
550
+ });
551
+ return {
552
+ method: "PATCH",
553
+ uri: `/_apis/wit/workitems/${uid}?api-version=${batchApiVersion}`,
554
+ headers: { "Content-Type": "application/json-patch+json" },
555
+ body: operations,
556
+ };
557
+ });
558
+ const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, {
559
+ method: "PATCH",
560
+ headers: {
561
+ "Authorization": `Bearer ${accessToken}`,
562
+ "Content-Type": "application/json",
563
+ "User-Agent": userAgentProvider(),
564
+ },
565
+ body: JSON.stringify(body),
566
+ });
567
+ if (!response.ok) {
568
+ throw new Error(`Failed to update work items in batch: ${response.statusText}`);
569
+ }
570
+ const result = await response.json();
571
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
572
+ }
573
+ if (action === "add_child") {
574
+ if (!parentId)
575
+ return { content: [{ type: "text", text: "parentId is required for add_child" }], isError: true };
576
+ if (!workItemType)
577
+ return { content: [{ type: "text", text: "workItemType is required for add_child" }], isError: true };
578
+ if (!items || items.length === 0)
579
+ return { content: [{ type: "text", text: "items is required for add_child" }], isError: true };
580
+ if (!resolvedProject) {
581
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to create child work items in.");
582
+ if ("response" in result)
583
+ return result.response;
584
+ resolvedProject = result.resolved;
585
+ }
586
+ if (items.length > 50) {
587
+ return { content: [{ type: "text", text: "A maximum of 50 child work items can be created in a single call." }], isError: true };
588
+ }
589
+ const orgUrl = connection.serverUrl;
590
+ const accessToken = await tokenProvider();
591
+ const body = items.map((item, x) => {
592
+ const encodedDescription = encodeFormattedValue(item.description, item.format);
593
+ const ops = [
594
+ { op: "add", path: "/id", value: `-${x + 1}` },
595
+ { op: "add", path: "/fields/System.Title", value: item.title },
596
+ { op: "add", path: "/fields/System.Description", value: encodedDescription },
597
+ { op: "add", path: "/fields/Microsoft.VSTS.TCM.ReproSteps", value: encodedDescription },
598
+ {
599
+ op: "add",
600
+ path: "/relations/-",
601
+ value: {
602
+ rel: "System.LinkTypes.Hierarchy-Reverse",
603
+ url: `${connection.serverUrl}/${resolvedProject}/_apis/wit/workItems/${parentId}`,
604
+ },
605
+ },
606
+ ];
607
+ if (item.areaPath && item.areaPath.trim().length > 0) {
608
+ ops.push({ op: "add", path: "/fields/System.AreaPath", value: item.areaPath });
609
+ }
610
+ if (item.iterationPath && item.iterationPath.trim().length > 0) {
611
+ ops.push({ op: "add", path: "/fields/System.IterationPath", value: item.iterationPath });
612
+ }
613
+ if (item.format && item.format === "Markdown") {
614
+ ops.push({ op: "add", path: "/multilineFieldsFormat/System.Description", value: item.format });
615
+ ops.push({ op: "add", path: "/multilineFieldsFormat/Microsoft.VSTS.TCM.ReproSteps", value: item.format });
616
+ }
617
+ return {
618
+ method: "PATCH",
619
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
620
+ uri: `/${encodeURIComponent(resolvedProject)}/_apis/wit/workitems/$${encodeURIComponent(workItemType)}?api-version=${batchApiVersion}`,
621
+ headers: { "Content-Type": "application/json-patch+json" },
622
+ body: ops,
623
+ };
624
+ });
625
+ const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, {
626
+ method: "PATCH",
627
+ headers: {
628
+ "Authorization": `Bearer ${accessToken}`,
629
+ "Content-Type": "application/json",
630
+ "User-Agent": userAgentProvider(),
631
+ },
632
+ body: JSON.stringify(body),
633
+ });
634
+ if (!response.ok) {
635
+ throw new Error(`Failed to update work items in batch: ${response.statusText}`);
636
+ }
637
+ const result = await response.json();
638
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
639
+ }
640
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
641
+ }
642
+ catch (error) {
643
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
644
+ const msgs = {
645
+ create: `Error creating work item: ${errorMessage}`,
646
+ update: `Error updating work item: ${errorMessage}`,
647
+ update_batch: `Error updating work items in batch: ${errorMessage}`,
648
+ add_child: `Error creating child work items: ${errorMessage}`,
649
+ };
650
+ return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true };
651
+ }
652
+ });
653
+ // --- wit_work_item_comment_write --------------------------------------------
654
+ server.tool(WORKITEM_TOOLS.wit_work_item_comment_write, "Write operations for work item comments. Use the action parameter to specify the operation.", {
655
+ 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)."),
656
+ 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."),
657
+ workItemId: z.coerce.number().min(1).optional().describe("The ID of the work item. Required for: add, update."),
658
+ text: z.string().optional().describe("The comment text. Required for: add, update."),
659
+ commentId: z.coerce.number().min(1).optional().describe("The ID of the comment to update. Required for: update."),
660
+ format: z.enum(["Markdown", "Html"]).optional().default("Markdown").describe("Format of the comment text. Optional, defaults to 'Markdown'."),
661
+ }, async ({ action, project, workItemId, text, commentId, format }) => {
662
+ try {
663
+ const connection = await connectionProvider();
664
+ let resolvedProject = project;
665
+ if (!resolvedProject) {
666
+ const label = action === "add" ? "add a work item comment in" : "update the work item comment in";
667
+ const result = await elicitProject(server, connection, `Select the Azure DevOps project to ${label}.`);
668
+ if ("response" in result)
669
+ return result.response;
670
+ resolvedProject = result.resolved;
671
+ }
672
+ if (!workItemId)
673
+ return { content: [{ type: "text", text: "workItemId is required" }], isError: true };
674
+ if (!text)
675
+ return { content: [{ type: "text", text: "text is required" }], isError: true };
676
+ const orgUrl = connection.serverUrl;
677
+ const accessToken = await tokenProvider();
678
+ const formatParameter = (format ?? "Markdown") === "Markdown" ? 0 : 1;
679
+ if (action === "add") {
680
+ const response = await fetch(`${orgUrl}/${encodeURIComponent(resolvedProject)}/_apis/wit/workItems/${workItemId}/comments?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, {
681
+ method: "POST",
682
+ headers: {
683
+ "Authorization": `Bearer ${accessToken}`,
684
+ "Content-Type": "application/json",
685
+ "User-Agent": userAgentProvider(),
686
+ },
687
+ body: JSON.stringify({ text }),
688
+ });
689
+ if (!response.ok) {
690
+ throw new Error(`Failed to add a work item comment: ${response.statusText}`);
691
+ }
692
+ return { content: [{ type: "text", text: await response.text() }] };
693
+ }
694
+ if (action === "update") {
695
+ if (!commentId)
696
+ return { content: [{ type: "text", text: "commentId is required for update" }], isError: true };
697
+ const response = await fetch(`${orgUrl}/${encodeURIComponent(resolvedProject)}/_apis/wit/workItems/${workItemId}/comments/${commentId}?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, {
698
+ method: "PATCH",
699
+ headers: {
700
+ "Authorization": `Bearer ${accessToken}`,
701
+ "Content-Type": "application/json",
702
+ "User-Agent": userAgentProvider(),
703
+ },
704
+ body: JSON.stringify({ text }),
705
+ });
706
+ if (!response.ok) {
707
+ throw new Error(`Failed to update work item comment: ${response.statusText}`);
708
+ }
709
+ return { content: [{ type: "text", text: await response.text() }] };
710
+ }
711
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
712
+ }
713
+ catch (error) {
714
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
715
+ const msgs = {
716
+ add: `Error adding work item comment: ${errorMessage}`,
717
+ update: `Error updating work item comment: ${errorMessage}`,
718
+ };
719
+ return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true };
720
+ }
721
+ });
722
+ // --- wit_work_item_link_write -----------------------------------------------
723
+ server.tool(WORKITEM_TOOLS.wit_work_item_link_write, "Write operations for work item links. Use the action parameter to specify the operation.", {
724
+ action: z
725
+ .enum(["link", "unlink", "link_to_pull_request", "add_artifact_link"])
726
+ .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)."),
727
+ 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."),
728
+ // link
729
+ updates: z
730
+ .array(z.object({
731
+ id: z.coerce.number().min(1).describe("The ID of the work item to update."),
732
+ linkToId: z.coerce.number().min(1).describe("The ID of the work item to link to."),
733
+ type: z
734
+ .enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by"])
735
+ .default("related")
736
+ .describe("Type of link. Defaults to 'related'."),
737
+ comment: z.string().optional().describe("Optional comment for the link."),
738
+ }))
739
+ .optional()
740
+ .describe("Link operations to apply. Required for: link."),
741
+ // unlink
742
+ id: z.coerce.number().min(1).optional().describe("Work item ID to remove links from. Required for: unlink."),
743
+ type: z
744
+ .enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by", "artifact"])
745
+ .optional()
746
+ .describe("Link type to remove. Required for: unlink."),
747
+ 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."),
748
+ // link_to_pull_request and add_artifact_link
749
+ projectId: z.string().optional().describe("The project ID (GUID). Required for: link_to_pull_request, and add_artifact_link (Git/Wiki types)."),
750
+ repositoryId: z.string().optional().describe("The repository ID. Required for: link_to_pull_request and add_artifact_link (Git types)."),
751
+ 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)."),
752
+ workItemId: z.coerce.number().min(1).optional().describe("The work item ID. Required for: link_to_pull_request, add_artifact_link."),
753
+ pullRequestProjectId: z.string().optional().describe("Project ID containing the pull request. Used for: link_to_pull_request. Defaults to projectId."),
754
+ // add_artifact_link
755
+ artifactUri: z.string().optional().describe("The complete VSTFS URI of the artifact. Used for: add_artifact_link. If provided, individual component parameters are ignored."),
756
+ branchName: z.string().optional().describe("The branch name. Used for: add_artifact_link (Branch type)."),
757
+ commitId: z.string().optional().describe("The commit SHA hash. Used for: add_artifact_link (Fixed in Commit type)."),
758
+ buildId: z.coerce.number().min(1).optional().describe("The build ID. Used for: add_artifact_link (Build, Found in build, Integrated in build types)."),
759
+ wikiId: z.string().optional().describe("The wiki ID (GUID). Used for: add_artifact_link (Wiki type)."),
760
+ pageId: z.coerce.number().min(1).optional().describe("The numeric wiki page ID. Used for: add_artifact_link (Wiki type). Takes precedence over pagePath."),
761
+ pagePath: z.string().optional().describe("The full wiki page path. Used for: add_artifact_link (Wiki type) when pageId is not provided."),
762
+ linkType: z
763
+ .enum([
764
+ "Branch",
765
+ "Build",
766
+ "Fixed in Changeset",
767
+ "Fixed in Commit",
768
+ "Found in build",
769
+ "Integrated in build",
770
+ "Model Link",
771
+ "Pull Request",
772
+ "Related Workitem",
773
+ "Result Attachment",
774
+ "Source Code File",
775
+ "Tag",
776
+ "Test Result",
777
+ "Wiki",
778
+ ])
779
+ .optional()
780
+ .describe("Type of artifact link. Used for: add_artifact_link. Defaults to 'Branch'."),
781
+ comment: z.string().optional().describe("Comment to include with the artifact link. Used for: add_artifact_link."),
782
+ }, async ({ action, project, updates, id, type, url, projectId, repositoryId, pullRequestId, workItemId, pullRequestProjectId, artifactUri, branchName, commitId, buildId, wikiId, pageId, pagePath, linkType, comment, }) => {
783
+ try {
784
+ const connection = await connectionProvider();
785
+ let resolvedProject = project;
786
+ if (action === "link") {
787
+ if (!updates || updates.length === 0)
788
+ return { content: [{ type: "text", text: "updates is required for link" }], isError: true };
789
+ if (!resolvedProject) {
790
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to link work items in.");
791
+ if ("response" in result)
792
+ return result.response;
793
+ resolvedProject = result.resolved;
794
+ }
795
+ const orgUrl = connection.serverUrl;
796
+ const accessToken = await tokenProvider();
797
+ const uniqueIds = Array.from(new Set(updates.map((update) => update.id)));
798
+ const body = uniqueIds.map((uid) => ({
799
+ method: "PATCH",
800
+ uri: `/_apis/wit/workitems/${uid}?api-version=${batchApiVersion}`,
801
+ headers: { "Content-Type": "application/json-patch+json" },
802
+ body: updates
803
+ .filter((update) => update.id === uid)
804
+ .map(({ linkToId, type: linkTypeName, comment: linkComment }) => ({
805
+ op: "add",
806
+ path: "/relations/-",
807
+ value: {
808
+ rel: `${getLinkTypeFromName(linkTypeName)}`,
809
+ url: `${orgUrl}/${resolvedProject}/_apis/wit/workItems/${linkToId}`,
810
+ attributes: { comment: linkComment || "" },
811
+ },
812
+ })),
813
+ }));
814
+ const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, {
815
+ method: "PATCH",
816
+ headers: {
817
+ "Authorization": `Bearer ${accessToken}`,
818
+ "Content-Type": "application/json",
819
+ "User-Agent": userAgentProvider(),
820
+ },
821
+ body: JSON.stringify(body),
822
+ });
823
+ if (!response.ok) {
824
+ throw new Error(`Failed to update work items in batch: ${response.statusText}`);
825
+ }
826
+ const result = await response.json();
827
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
828
+ }
829
+ if (action === "unlink") {
830
+ if (!id)
831
+ return { content: [{ type: "text", text: "id is required for unlink" }], isError: true };
832
+ if (!type)
833
+ return { content: [{ type: "text", text: "type is required for unlink" }], isError: true };
834
+ if (!resolvedProject) {
835
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to unlink work items in.");
836
+ if ("response" in result)
837
+ return result.response;
838
+ resolvedProject = result.resolved;
839
+ }
840
+ const workItemApi = await connection.getWorkItemTrackingApi();
841
+ const workItem = await workItemApi.getWorkItem(id, undefined, undefined, WorkItemExpand.Relations, resolvedProject);
842
+ const relations = workItem.relations ?? [];
843
+ const linkTypeName = getLinkTypeFromName(type);
844
+ let relationIndexes = [];
845
+ if (url && url.trim().length > 0) {
846
+ relationIndexes = relations.map((relation, idx) => (relation.rel === linkTypeName && relation.url === url ? idx : -1)).filter((idx) => idx !== -1);
847
+ }
848
+ else {
849
+ relationIndexes = relations.map((relation, idx) => (relation.rel === linkTypeName ? idx : -1)).filter((idx) => idx !== -1);
850
+ }
851
+ if (relationIndexes.length === 0) {
852
+ return {
853
+ content: [{ type: "text", text: `No matching relations found for link type '${type}'${url ? ` and URL '${url}'` : ""}.\n${JSON.stringify(relations, null, 2)}` }],
854
+ isError: true,
855
+ };
856
+ }
857
+ const removedRelations = relationIndexes.map((idx) => relations[idx]);
858
+ relationIndexes.sort((a, b) => b - a);
859
+ const apiUpdates = relationIndexes.map((idx) => ({ op: "remove", path: `/relations/${idx}` }));
860
+ const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id, resolvedProject);
861
+ return {
862
+ content: [
863
+ {
864
+ type: "text",
865
+ text: `Removed ${removedRelations.length} link(s) of type '${type}':\n` +
866
+ JSON.stringify(removedRelations, null, 2) +
867
+ `\n\nUpdated work item result:\n` +
868
+ JSON.stringify(updatedWorkItem, null, 2),
869
+ },
870
+ ],
871
+ isError: false,
872
+ };
873
+ }
874
+ if (action === "link_to_pull_request") {
875
+ if (!projectId)
876
+ return { content: [{ type: "text", text: "projectId is required for link_to_pull_request" }], isError: true };
877
+ if (!repositoryId)
878
+ return { content: [{ type: "text", text: "repositoryId is required for link_to_pull_request" }], isError: true };
879
+ if (pullRequestId === undefined)
880
+ return { content: [{ type: "text", text: "pullRequestId is required for link_to_pull_request" }], isError: true };
881
+ if (!workItemId)
882
+ return { content: [{ type: "text", text: "workItemId is required for link_to_pull_request" }], isError: true };
883
+ const workItemTrackingApi = await connection.getWorkItemTrackingApi();
884
+ const artifactProjectId = pullRequestProjectId && pullRequestProjectId.trim() !== "" ? pullRequestProjectId : projectId;
885
+ const artifactPathValue = `${artifactProjectId}/${repositoryId}/${pullRequestId}`;
886
+ const vstfsUrl = `vstfs:///Git/PullRequestId/${encodeURIComponent(artifactPathValue)}`;
887
+ const patchDocument = [
888
+ {
889
+ op: "add",
890
+ path: "/relations/-",
891
+ value: {
892
+ rel: "ArtifactLink",
893
+ url: vstfsUrl,
894
+ attributes: { name: "Pull Request" },
895
+ },
896
+ },
897
+ ];
898
+ const workItem = await workItemTrackingApi.updateWorkItem({}, patchDocument, workItemId, projectId);
899
+ if (!workItem) {
900
+ return { content: [{ type: "text", text: "Work item update failed" }], isError: true };
901
+ }
902
+ return {
903
+ content: [{ type: "text", text: JSON.stringify({ workItemId, pullRequestId, success: true }, null, 2) }],
904
+ };
905
+ }
906
+ if (action === "add_artifact_link") {
907
+ if (!workItemId)
908
+ return { content: [{ type: "text", text: "workItemId is required for add_artifact_link" }], isError: true };
909
+ if (!resolvedProject) {
910
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to add the artifact link in.");
911
+ if ("response" in result)
912
+ return result.response;
913
+ resolvedProject = result.resolved;
914
+ }
915
+ const workItemTrackingApi = await connection.getWorkItemTrackingApi();
916
+ const effectiveLinkType = linkType ?? "Branch";
917
+ let finalArtifactUri;
918
+ if (artifactUri) {
919
+ finalArtifactUri = artifactUri;
920
+ }
921
+ else {
922
+ switch (effectiveLinkType) {
923
+ case "Branch":
924
+ if (!projectId || !repositoryId || !branchName) {
925
+ return { content: [{ type: "text", text: "For 'Branch' links, 'projectId', 'repositoryId', and 'branchName' are required." }], isError: true };
926
+ }
927
+ finalArtifactUri = `vstfs:///Git/Ref/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2FGB${encodeURIComponent(branchName)}`;
928
+ break;
929
+ case "Fixed in Commit":
930
+ if (!projectId || !repositoryId || !commitId) {
931
+ return { content: [{ type: "text", text: "For 'Fixed in Commit' links, 'projectId', 'repositoryId', and 'commitId' are required." }], isError: true };
932
+ }
933
+ finalArtifactUri = `vstfs:///Git/Commit/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2F${encodeURIComponent(commitId)}`;
934
+ break;
935
+ case "Pull Request":
936
+ if (!projectId || !repositoryId || pullRequestId === undefined) {
937
+ return { content: [{ type: "text", text: "For 'Pull Request' links, 'projectId', 'repositoryId', and 'pullRequestId' are required." }], isError: true };
938
+ }
939
+ finalArtifactUri = `vstfs:///Git/PullRequestId/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2F${encodeURIComponent(pullRequestId.toString())}`;
940
+ break;
941
+ case "Build":
942
+ case "Found in build":
943
+ case "Integrated in build":
944
+ if (buildId === undefined) {
945
+ return { content: [{ type: "text", text: `For '${effectiveLinkType}' links, 'buildId' is required.` }], isError: true };
946
+ }
947
+ finalArtifactUri = `vstfs:///Build/Build/${encodeURIComponent(buildId.toString())}`;
948
+ break;
949
+ case "Wiki": {
950
+ if (!projectId || !wikiId) {
951
+ return { content: [{ type: "text", text: "For 'Wiki' links, 'projectId', 'wikiId', and 'pagePath' are required." }], isError: true };
952
+ }
953
+ let resolvedPagePath = pagePath;
954
+ if (pageId !== undefined) {
955
+ const orgUrl = connection.serverUrl;
956
+ const accessToken = await tokenProvider();
957
+ const pageResponse = await fetch(`${orgUrl}/${encodeURIComponent(resolvedProject)}/_apis/wiki/wikis/${encodeURIComponent(wikiId)}/pages/${pageId}?api-version=7.1`, {
958
+ headers: {
959
+ "Authorization": `Bearer ${accessToken}`,
960
+ "User-Agent": userAgentProvider(),
961
+ },
962
+ });
963
+ if (!pageResponse.ok) {
964
+ return { content: [{ type: "text", text: `Failed to look up wiki page ID ${pageId}: ${pageResponse.statusText}` }], isError: true };
965
+ }
966
+ const pageData = await pageResponse.json();
967
+ resolvedPagePath = pageData.path;
968
+ }
969
+ if (!resolvedPagePath) {
970
+ return { content: [{ type: "text", text: "For 'Wiki' links, 'pageId' or 'pagePath' is required." }], isError: true };
971
+ }
972
+ const normalizedPath = resolvedPagePath.startsWith("/") ? resolvedPagePath.slice(1) : resolvedPagePath;
973
+ const encodedPath = normalizedPath.split("/").map(encodeURIComponent).join("%2F");
974
+ finalArtifactUri = `vstfs:///Wiki/WikiPage/${encodeURIComponent(projectId)}%2F${encodeURIComponent(wikiId)}%2F${encodedPath}`;
975
+ break;
976
+ }
977
+ default:
978
+ return {
979
+ content: [{ type: "text", text: `URI building from components is not supported for link type '${effectiveLinkType}'. Please provide the full 'artifactUri' instead.` }],
980
+ isError: true,
981
+ };
982
+ }
983
+ }
984
+ const patchDocument = [
985
+ {
986
+ op: "add",
987
+ path: "/relations/-",
988
+ value: {
989
+ rel: "ArtifactLink",
990
+ url: finalArtifactUri,
991
+ attributes: {
992
+ name: getArtifactLinkAttributeName(effectiveLinkType),
993
+ ...(comment && { comment }),
994
+ },
995
+ },
996
+ },
997
+ ];
998
+ const workItem = await workItemTrackingApi.updateWorkItem({}, patchDocument, workItemId, resolvedProject);
999
+ if (!workItem) {
1000
+ return { content: [{ type: "text", text: "Work item update failed" }], isError: true };
1001
+ }
1002
+ return {
1003
+ content: [
1004
+ {
1005
+ type: "text",
1006
+ text: JSON.stringify({ workItemId, artifactUri: finalArtifactUri, linkType: effectiveLinkType, comment: comment || null, success: true }, null, 2),
1007
+ },
1008
+ ],
1009
+ };
1010
+ }
1011
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
1012
+ }
1013
+ catch (error) {
1014
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
1015
+ const msgs = {
1016
+ link: `Error linking work items: ${errorMessage}`,
1017
+ unlink: `Error unlinking work item: ${errorMessage}`,
1018
+ link_to_pull_request: `Error linking work item to pull request: ${errorMessage}`,
1019
+ add_artifact_link: `Error adding artifact link to work item: ${errorMessage}`,
1020
+ };
1021
+ return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true };
1022
+ }
1023
+ });
1347
1024
  }
1348
1025
  function getMimeType(fileName) {
1349
1026
  const ext = fileName?.split(".").pop()?.toLowerCase();