@azure-devops/mcp 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,12 +73,12 @@ Interact with these Azure DevOps services:
73
73
  - **wit_get_query**: Get a query by its ID or path.
74
74
  - **wit_get_query_results_by_id**: Retrieve the results of a work item query given the query ID.
75
75
  - **wit_update_work_items_batch**: Update work items in batch.
76
- - **wit_close_and_link_workitem_duplicates**: Close duplicate work items by ID.
77
76
  - **wit_work_items_link**: Link work items together in batch.
78
77
 
79
78
  #### Deprecated Tools
80
79
 
81
80
  - **wit_add_child_work_item**: Replaced by `wit_add_child_work_items` to allow creating one or more child items per call.
81
+ - **wit_close_and_link_workitem_duplicates**: This tool is no longer needed. Finding and marking duplicates can be done with other tools.
82
82
 
83
83
  ### 📁 Repositories
84
84
 
@@ -96,9 +96,10 @@ Interact with these Azure DevOps services:
96
96
  - **repo_create_pull_request**: Create a new pull request.
97
97
  - **repo_update_pull_request_status**: Update the status of an existing pull request to active or abandoned.
98
98
  - **repo_update_pull_request_reviewers**: Add or remove reviewers for an existing pull request.
99
- - **repo_reply_to_comment**: Reply to a specific comment on a pull request.
100
- - **repo_resolve_comment**: Resolve a specific comment thread on a pull request.
101
- - **repo_search_commits**: Search for commits.
99
+ - **repo_reply_to_comment**: Replies to a specific comment on a pull request.
100
+ - **repo_resolve_comment**: Resolves a specific comment thread on a pull request.
101
+ - **repo_search_commits**: Searches for commits.
102
+ - **repo_create_pull_request_thread**: Creates a new comment thread on a pull request.
102
103
 
103
104
  ### 🛰️ Builds
104
105
 
@@ -19,6 +19,7 @@ const REPO_TOOLS = {
19
19
  update_pull_request_status: "repo_update_pull_request_status",
20
20
  update_pull_request_reviewers: "repo_update_pull_request_reviewers",
21
21
  reply_to_comment: "repo_reply_to_comment",
22
+ create_pull_request_thread: "repo_create_pull_request_thread",
22
23
  resolve_comment: "repo_resolve_comment",
23
24
  search_commits: "repo_search_commits",
24
25
  list_pull_requests_by_commits: "repo_list_pull_requests_by_commits",
@@ -31,6 +32,26 @@ function branchesFilterOutIrrelevantProperties(branches, top) {
31
32
  .sort((a, b) => b.localeCompare(a))
32
33
  .slice(0, top);
33
34
  }
35
+ /**
36
+ * Trims comment data to essential properties, filtering out deleted comments
37
+ * @param comments Array of comments to trim (can be undefined/null)
38
+ * @returns Array of trimmed comment objects with essential properties only
39
+ */
40
+ function trimComments(comments) {
41
+ return comments
42
+ ?.filter((comment) => !comment.isDeleted) // Exclude deleted comments
43
+ ?.map((comment) => ({
44
+ id: comment.id,
45
+ author: {
46
+ displayName: comment.author?.displayName,
47
+ uniqueName: comment.author?.uniqueName,
48
+ },
49
+ content: comment.content,
50
+ publishedDate: comment.publishedDate,
51
+ lastUpdatedDate: comment.lastUpdatedDate,
52
+ lastContentUpdatedDate: comment.lastContentUpdatedDate,
53
+ }));
54
+ }
34
55
  function pullRequestStatusStringToInt(status) {
35
56
  switch (status) {
36
57
  case "Abandoned":
@@ -242,13 +263,27 @@ function configureRepoTools(server, tokenProvider, connectionProvider) {
242
263
  baseIteration: z.number().optional().describe("The base iteration ID for which to retrieve threads. Optional, defaults to the latest base iteration."),
243
264
  top: z.number().default(100).describe("The maximum number of threads to return."),
244
265
  skip: z.number().default(0).describe("The number of threads to skip."),
245
- }, async ({ repositoryId, pullRequestId, project, iteration, baseIteration, top, skip }) => {
266
+ fullResponse: z.boolean().optional().default(false).describe("Return full thread JSON response instead of trimmed data."),
267
+ }, async ({ repositoryId, pullRequestId, project, iteration, baseIteration, top, skip, fullResponse }) => {
246
268
  const connection = await connectionProvider();
247
269
  const gitApi = await connection.getGitApi();
248
270
  const threads = await gitApi.getThreads(repositoryId, pullRequestId, project, iteration, baseIteration);
249
271
  const paginatedThreads = threads?.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top);
272
+ if (fullResponse) {
273
+ return {
274
+ content: [{ type: "text", text: JSON.stringify(paginatedThreads, null, 2) }],
275
+ };
276
+ }
277
+ // Return trimmed thread data focusing on essential information
278
+ const trimmedThreads = paginatedThreads?.map((thread) => ({
279
+ id: thread.id,
280
+ publishedDate: thread.publishedDate,
281
+ lastUpdatedDate: thread.lastUpdatedDate,
282
+ status: thread.status,
283
+ comments: trimComments(thread.comments),
284
+ }));
250
285
  return {
251
- content: [{ type: "text", text: JSON.stringify(paginatedThreads, null, 2) }],
286
+ content: [{ type: "text", text: JSON.stringify(trimmedThreads, null, 2) }],
252
287
  };
253
288
  });
254
289
  server.tool(REPO_TOOLS.list_pull_request_thread_comments, "Retrieve a list of comments in a pull request thread.", {
@@ -258,14 +293,22 @@ function configureRepoTools(server, tokenProvider, connectionProvider) {
258
293
  project: z.string().optional().describe("Project ID or project name (optional)"),
259
294
  top: z.number().default(100).describe("The maximum number of comments to return."),
260
295
  skip: z.number().default(0).describe("The number of comments to skip."),
261
- }, async ({ repositoryId, pullRequestId, threadId, project, top, skip }) => {
296
+ fullResponse: z.boolean().optional().default(false).describe("Return full comment JSON response instead of trimmed data."),
297
+ }, async ({ repositoryId, pullRequestId, threadId, project, top, skip, fullResponse }) => {
262
298
  const connection = await connectionProvider();
263
299
  const gitApi = await connection.getGitApi();
264
300
  // Get thread comments - GitApi uses getComments for retrieving comments from a specific thread
265
301
  const comments = await gitApi.getComments(repositoryId, pullRequestId, threadId, project);
266
302
  const paginatedComments = comments?.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top);
303
+ if (fullResponse) {
304
+ return {
305
+ content: [{ type: "text", text: JSON.stringify(paginatedComments, null, 2) }],
306
+ };
307
+ }
308
+ // Return trimmed comment data focusing on essential information
309
+ const trimmedComments = trimComments(paginatedComments);
267
310
  return {
268
- content: [{ type: "text", text: JSON.stringify(paginatedComments, null, 2) }],
311
+ content: [{ type: "text", text: JSON.stringify(trimmedComments, null, 2) }],
269
312
  };
270
313
  });
271
314
  server.tool(REPO_TOOLS.list_branches_by_repo, "Retrieve a list of branches for a given repository.", {
@@ -346,25 +389,106 @@ function configureRepoTools(server, tokenProvider, connectionProvider) {
346
389
  threadId: z.number().describe("The ID of the thread to which the comment will be added."),
347
390
  content: z.string().describe("The content of the comment to be added."),
348
391
  project: z.string().optional().describe("Project ID or project name (optional)"),
349
- }, async ({ repositoryId, pullRequestId, threadId, content, project }) => {
392
+ fullResponse: z.boolean().optional().default(false).describe("Return full comment JSON response instead of a simple confirmation message."),
393
+ }, async ({ repositoryId, pullRequestId, threadId, content, project, fullResponse }) => {
350
394
  const connection = await connectionProvider();
351
395
  const gitApi = await connection.getGitApi();
352
396
  const comment = await gitApi.createComment({ content }, repositoryId, pullRequestId, threadId, project);
397
+ // Check if the comment was successfully created
398
+ if (!comment) {
399
+ return {
400
+ content: [{ type: "text", text: `Error: Failed to add comment to thread ${threadId}. The comment was not created successfully.` }],
401
+ isError: true,
402
+ };
403
+ }
404
+ if (fullResponse) {
405
+ return {
406
+ content: [{ type: "text", text: JSON.stringify(comment, null, 2) }],
407
+ };
408
+ }
353
409
  return {
354
- content: [{ type: "text", text: JSON.stringify(comment, null, 2) }],
410
+ content: [{ type: "text", text: `Comment successfully added to thread ${threadId}.` }],
411
+ };
412
+ });
413
+ server.tool(REPO_TOOLS.create_pull_request_thread, "Creates a new comment thread on a pull request.", {
414
+ repositoryId: z.string().describe("The ID of the repository where the pull request is located."),
415
+ pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."),
416
+ content: z.string().describe("The content of the comment to be added."),
417
+ project: z.string().optional().describe("Project ID or project name (optional)"),
418
+ filePath: z.string().optional().describe("The path of the file where the comment thread will be created. (optional)"),
419
+ rightFileStartLine: z.number().optional().describe("Position of first character of the thread's span in right file. The line number of a thread's position. Starts at 1. (optional)"),
420
+ rightFileStartOffset: z
421
+ .number()
422
+ .optional()
423
+ .describe("Position of first character of the thread's span in right file. The line number of a thread's position. The character offset of a thread's position inside of a line. Starts at 1. Must only be set if rightFileStartLine is also specified. (optional)"),
424
+ rightFileEndLine: z
425
+ .number()
426
+ .optional()
427
+ .describe("Position of last character of the thread's span in right file. The line number of a thread's position. Starts at 1. Must only be set if rightFileStartLine is also specified. (optional)"),
428
+ rightFileEndOffset: z
429
+ .number()
430
+ .optional()
431
+ .describe("Position of last character of the thread's span in right file. The character offset of a thread's position inside of a line. Must only be set if rightFileEndLine is also specified. (optional)"),
432
+ }, async ({ repositoryId, pullRequestId, content, project, filePath, rightFileStartLine, rightFileStartOffset, rightFileEndLine, rightFileEndOffset }) => {
433
+ const connection = await connectionProvider();
434
+ const gitApi = await connection.getGitApi();
435
+ const threadContext = { filePath: filePath };
436
+ if (rightFileStartLine !== undefined) {
437
+ if (rightFileStartLine < 1) {
438
+ throw new Error("rightFileStartLine must be greater than or equal to 1.");
439
+ }
440
+ threadContext.rightFileStart = { line: rightFileStartLine };
441
+ if (rightFileStartOffset !== undefined) {
442
+ if (rightFileStartOffset < 1) {
443
+ throw new Error("rightFileStartOffset must be greater than or equal to 1.");
444
+ }
445
+ threadContext.rightFileStart.offset = rightFileStartOffset;
446
+ }
447
+ }
448
+ if (rightFileEndLine !== undefined) {
449
+ if (rightFileStartLine === undefined) {
450
+ throw new Error("rightFileEndLine must only be specified if rightFileStartLine is also specified.");
451
+ }
452
+ if (rightFileEndLine < 1) {
453
+ throw new Error("rightFileEndLine must be greater than or equal to 1.");
454
+ }
455
+ threadContext.rightFileEnd = { line: rightFileEndLine };
456
+ if (rightFileEndOffset !== undefined) {
457
+ if (rightFileEndOffset < 1) {
458
+ throw new Error("rightFileEndOffset must be greater than or equal to 1.");
459
+ }
460
+ threadContext.rightFileEnd.offset = rightFileEndOffset;
461
+ }
462
+ }
463
+ const thread = await gitApi.createThread({ comments: [{ content: content }], threadContext: threadContext }, repositoryId, pullRequestId, project);
464
+ return {
465
+ content: [{ type: "text", text: JSON.stringify(thread, null, 2) }],
355
466
  };
356
467
  });
357
468
  server.tool(REPO_TOOLS.resolve_comment, "Resolves a specific comment thread on a pull request.", {
358
469
  repositoryId: z.string().describe("The ID of the repository where the pull request is located."),
359
470
  pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."),
360
471
  threadId: z.number().describe("The ID of the thread to be resolved."),
361
- }, async ({ repositoryId, pullRequestId, threadId }) => {
472
+ fullResponse: z.boolean().optional().default(false).describe("Return full thread JSON response instead of a simple confirmation message."),
473
+ }, async ({ repositoryId, pullRequestId, threadId, fullResponse }) => {
362
474
  const connection = await connectionProvider();
363
475
  const gitApi = await connection.getGitApi();
364
476
  const thread = await gitApi.updateThread({ status: 2 }, // 2 corresponds to "Resolved" status
365
477
  repositoryId, pullRequestId, threadId);
478
+ // Check if the thread was successfully resolved
479
+ if (!thread) {
480
+ return {
481
+ content: [{ type: "text", text: `Error: Failed to resolve thread ${threadId}. The thread status was not updated successfully.` }],
482
+ isError: true,
483
+ };
484
+ }
485
+ if (fullResponse) {
486
+ return {
487
+ content: [{ type: "text", text: JSON.stringify(thread, null, 2) }],
488
+ };
489
+ }
366
490
  return {
367
- content: [{ type: "text", text: JSON.stringify(thread, null, 2) }],
491
+ content: [{ type: "text", text: `Thread ${threadId} was successfully resolved.` }],
368
492
  };
369
493
  });
370
494
  const gitVersionTypeStrings = Object.values(GitVersionType).filter((value) => typeof value === "string");
@@ -17,17 +17,17 @@ function configureSearchTools(server, tokenProvider, connectionProvider, userAge
17
17
  path: z.array(z.string()).optional().describe("Filter by paths"),
18
18
  branch: z.array(z.string()).optional().describe("Filter by branches"),
19
19
  includeFacets: z.boolean().default(false).describe("Include facets in the search results"),
20
- $skip: z.number().default(0).describe("Number of results to skip"),
21
- $top: z.number().default(5).describe("Maximum number of results to return"),
22
- }, async ({ searchText, project, repository, path, branch, includeFacets, $skip, $top }) => {
20
+ skip: z.number().default(0).describe("Number of results to skip"),
21
+ top: z.number().default(5).describe("Maximum number of results to return"),
22
+ }, async ({ searchText, project, repository, path, branch, includeFacets, skip, top }) => {
23
23
  const accessToken = await tokenProvider();
24
24
  const connection = await connectionProvider();
25
25
  const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/codesearchresults?api-version=${apiVersion}`;
26
26
  const requestBody = {
27
27
  searchText,
28
28
  includeFacets,
29
- $skip,
30
- $top,
29
+ $skip: skip,
30
+ $top: top,
31
31
  };
32
32
  const filters = {};
33
33
  if (project && project.length > 0)
@@ -66,16 +66,16 @@ function configureSearchTools(server, tokenProvider, connectionProvider, userAge
66
66
  project: z.array(z.string()).optional().describe("Filter by projects"),
67
67
  wiki: z.array(z.string()).optional().describe("Filter by wiki names"),
68
68
  includeFacets: z.boolean().default(false).describe("Include facets in the search results"),
69
- $skip: z.number().default(0).describe("Number of results to skip"),
70
- $top: z.number().default(10).describe("Maximum number of results to return"),
71
- }, async ({ searchText, project, wiki, includeFacets, $skip, $top }) => {
69
+ skip: z.number().default(0).describe("Number of results to skip"),
70
+ top: z.number().default(10).describe("Maximum number of results to return"),
71
+ }, async ({ searchText, project, wiki, includeFacets, skip, top }) => {
72
72
  const accessToken = await tokenProvider();
73
73
  const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/wikisearchresults?api-version=${apiVersion}`;
74
74
  const requestBody = {
75
75
  searchText,
76
76
  includeFacets,
77
- $skip,
78
- $top,
77
+ $skip: skip,
78
+ $top: top,
79
79
  };
80
80
  const filters = {};
81
81
  if (project && project.length > 0)
@@ -110,16 +110,16 @@ function configureSearchTools(server, tokenProvider, connectionProvider, userAge
110
110
  state: z.array(z.string()).optional().describe("Filter by work item states"),
111
111
  assignedTo: z.array(z.string()).optional().describe("Filter by assigned to users"),
112
112
  includeFacets: z.boolean().default(false).describe("Include facets in the search results"),
113
- $skip: z.number().default(0).describe("Number of results to skip for pagination"),
114
- $top: z.number().default(10).describe("Number of results to return"),
115
- }, async ({ searchText, project, areaPath, workItemType, state, assignedTo, includeFacets, $skip, $top }) => {
113
+ skip: z.number().default(0).describe("Number of results to skip for pagination"),
114
+ top: z.number().default(10).describe("Number of results to return"),
115
+ }, async ({ searchText, project, areaPath, workItemType, state, assignedTo, includeFacets, skip, top }) => {
116
116
  const accessToken = await tokenProvider();
117
117
  const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/workitemsearchresults?api-version=${apiVersion}`;
118
118
  const requestBody = {
119
119
  searchText,
120
120
  includeFacets,
121
- $skip,
122
- $top,
121
+ $skip: skip,
122
+ $top: top,
123
123
  };
124
124
  const filters = {};
125
125
  if (project && project.length > 0)
@@ -3,14 +3,6 @@
3
3
  import { QueryExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
4
4
  import { z } from "zod";
5
5
  import { batchApiVersion, markdownCommentsApiVersion, getEnumKeys, safeEnumConvert } from "../utils.js";
6
- /**
7
- * Converts Operation enum key to lowercase string for API usage
8
- * @param operation The Operation enum key (e.g., "Add", "Replace", "Remove")
9
- * @returns Lowercase string for API usage (e.g., "add", "replace", "remove")
10
- */
11
- function operationToApiString(operation) {
12
- return operation.toLowerCase();
13
- }
14
6
  const WORKITEM_TOOLS = {
15
7
  my_work_items: "wit_my_work_items",
16
8
  list_backlogs: "wit_list_backlogs",
@@ -28,7 +20,6 @@ const WORKITEM_TOOLS = {
28
20
  get_query: "wit_get_query",
29
21
  get_query_results_by_id: "wit_get_query_results_by_id",
30
22
  update_work_items_batch: "wit_update_work_items_batch",
31
- close_and_link_workitem_duplicates: "wit_close_and_link_workitem_duplicates",
32
23
  work_items_link: "wit_work_items_link",
33
24
  };
34
25
  function getLinkTypeFromName(name) {
@@ -353,7 +344,12 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
353
344
  id: z.number().describe("The ID of the work item to update."),
354
345
  updates: z
355
346
  .array(z.object({
356
- op: z.enum(["Add", "Replace", "Remove"]).default("Add").describe("The operation to perform on the field."),
347
+ op: z
348
+ .string()
349
+ .transform((val) => val.toLowerCase())
350
+ .pipe(z.enum(["add", "replace", "remove"]))
351
+ .default("add")
352
+ .describe("The operation to perform on the field."),
357
353
  path: z.string().describe("The path of the field to update, e.g., '/fields/System.Title'."),
358
354
  value: z.string().describe("The new value for the field. This is required for 'Add' and 'Replace' operations, and should be omitted for 'Remove' operations."),
359
355
  }))
@@ -364,7 +360,7 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
364
360
  // Convert operation names to lowercase for API
365
361
  const apiUpdates = updates.map((update) => ({
366
362
  ...update,
367
- op: operationToApiString(update.op),
363
+ op: update.op,
368
364
  }));
369
365
  const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id);
370
366
  return {
@@ -577,52 +573,5 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
577
573
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
578
574
  };
579
575
  });
580
- server.tool(WORKITEM_TOOLS.close_and_link_workitem_duplicates, "Close duplicate work items by id.", {
581
- id: z.number().describe("The ID of the work item to close and link duplicates to."),
582
- duplicateIds: z.array(z.number()).describe("An array of IDs of the duplicate work items to close and link to the specified work item."),
583
- project: z.string().describe("The name or ID of the Azure DevOps project."),
584
- state: z.string().default("Removed").describe("The state to set for the duplicate work items. Defaults to 'Removed'."),
585
- }, async ({ id, duplicateIds, project, state }) => {
586
- const connection = await connectionProvider();
587
- const body = duplicateIds.map((duplicateId) => ({
588
- method: "PATCH",
589
- uri: `/_apis/wit/workitems/${duplicateId}?api-version=${batchApiVersion}`,
590
- headers: {
591
- "Content-Type": "application/json-patch+json",
592
- },
593
- body: [
594
- {
595
- op: "add",
596
- path: "/fields/System.State",
597
- value: `${state}`,
598
- },
599
- {
600
- op: "add",
601
- path: "/relations/-",
602
- value: {
603
- rel: "System.LinkTypes.Duplicate-Reverse",
604
- url: `${connection.serverUrl}/${project}/_apis/wit/workItems/${id}`,
605
- },
606
- },
607
- ],
608
- }));
609
- const accessToken = await tokenProvider();
610
- const response = await fetch(`${connection.serverUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, {
611
- method: "PATCH",
612
- headers: {
613
- "Authorization": `Bearer ${accessToken.token}`,
614
- "Content-Type": "application/json",
615
- "User-Agent": userAgentProvider(),
616
- },
617
- body: JSON.stringify(body),
618
- });
619
- if (!response.ok) {
620
- throw new Error(`Failed to update work items in batch: ${response.statusText}`);
621
- }
622
- const result = await response.json();
623
- return {
624
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
625
- };
626
- });
627
576
  }
628
577
  export { WORKITEM_TOOLS, configureWorkItemTools };
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const packageVersion = "1.2.0";
1
+ export const packageVersion = "1.2.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azure-devops/mcp",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "MCP server for interacting with Azure DevOps",
5
5
  "license": "MIT",
6
6
  "author": "Microsoft Corporation",