@azure-devops/mcp 2.4.0 → 2.5.0-nightly.20260319

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.
@@ -1,6 +1,6 @@
1
1
  // Copyright (c) Microsoft Corporation.
2
2
  // Licensed under the MIT License.
3
- import { PullRequestStatus, GitVersionType, GitPullRequestQueryType, CommentThreadStatus, GitPullRequestMergeStrategy, } from "azure-devops-node-api/interfaces/GitInterfaces.js";
3
+ import { PullRequestStatus, GitVersionType, GitPullRequestQueryType, CommentThreadStatus, GitPullRequestMergeStrategy, VersionControlRecursionType, } from "azure-devops-node-api/interfaces/GitInterfaces.js";
4
4
  import { z } from "zod";
5
5
  import { getCurrentUserDetails, getUserIdFromEmail } from "./auth.js";
6
6
  import { getEnumKeys } from "../utils.js";
@@ -23,6 +23,8 @@ const REPO_TOOLS = {
23
23
  update_pull_request_thread: "repo_update_pull_request_thread",
24
24
  search_commits: "repo_search_commits",
25
25
  list_pull_requests_by_commits: "repo_list_pull_requests_by_commits",
26
+ vote_pull_request: "repo_vote_pull_request",
27
+ list_directory: "repo_list_directory",
26
28
  };
27
29
  function branchesFilterOutIrrelevantProperties(branches, top) {
28
30
  return branches
@@ -84,6 +86,9 @@ function filterReposByName(repositories, repoNameFilter) {
84
86
  return filteredByName;
85
87
  }
86
88
  function trimPullRequest(pr, includeDescription = false) {
89
+ if (!pr) {
90
+ return null;
91
+ }
87
92
  return {
88
93
  pullRequestId: pr.pullRequestId,
89
94
  codeReviewId: pr.codeReviewId,
@@ -103,18 +108,36 @@ function trimPullRequest(pr, includeDescription = false) {
103
108
  project: pr.repository?.project?.name,
104
109
  };
105
110
  }
111
+ // Helper function to build a version descriptor from branch or commit
112
+ function buildVersionDescriptor(version, versionType) {
113
+ if (!version) {
114
+ return undefined;
115
+ }
116
+ const versionTypeMap = {
117
+ Branch: GitVersionType.Branch,
118
+ Commit: GitVersionType.Commit,
119
+ Tag: GitVersionType.Tag,
120
+ };
121
+ return {
122
+ version: version,
123
+ versionType: versionTypeMap[versionType || "Branch"] ?? GitVersionType.Branch,
124
+ };
125
+ }
106
126
  function configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider) {
107
127
  server.tool(REPO_TOOLS.create_pull_request, "Create a new pull request.", {
108
- repositoryId: z.string().describe("The ID of the repository where the pull request will be created."),
128
+ repositoryId: z
129
+ .string()
130
+ .describe("The ID or name of the repository where the pull request will be created. When using a repository name instead of a GUID, the project parameter must also be provided."),
109
131
  sourceRefName: z.string().describe("The source branch name for the pull request, e.g., 'refs/heads/feature-branch'."),
110
132
  targetRefName: z.string().describe("The target branch name for the pull request, e.g., 'refs/heads/main'."),
111
133
  title: z.string().describe("The title of the pull request."),
112
134
  description: z.string().max(4000).optional().describe("The description of the pull request. Must not be longer than 4000 characters. Optional."),
113
135
  isDraft: z.boolean().optional().default(false).describe("Indicates whether the pull request is a draft. Defaults to false."),
136
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
114
137
  workItems: z.string().optional().describe("Work item IDs to associate with the pull request, space-separated."),
115
138
  forkSourceRepositoryId: z.string().optional().describe("The ID of the fork repository that the pull request originates from. Optional, used when creating a pull request from a fork."),
116
139
  labels: z.array(z.string()).optional().describe("Array of label names to add to the pull request after creation."),
117
- }, async ({ repositoryId, sourceRefName, targetRefName, title, description, isDraft, workItems, forkSourceRepositoryId, labels }) => {
140
+ }, async ({ repositoryId, sourceRefName, targetRefName, title, description, isDraft, project, workItems, forkSourceRepositoryId, labels }) => {
118
141
  try {
119
142
  const connection = await connectionProvider();
120
143
  const gitApi = await connection.getGitApi();
@@ -127,7 +150,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
127
150
  }
128
151
  : undefined;
129
152
  const labelDefinitions = labels ? labels.map((label) => ({ name: label })) : undefined;
130
- const pullRequest = await gitApi.createPullRequest({
153
+ let pullRequest = await gitApi.createPullRequest({
131
154
  sourceRefName,
132
155
  targetRefName,
133
156
  title,
@@ -136,8 +159,25 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
136
159
  workItemRefs: workItemRefs,
137
160
  forkSource,
138
161
  labels: labelDefinitions,
139
- }, repositoryId);
162
+ supportsIterations: true,
163
+ }, repositoryId, project);
164
+ if (!pullRequest) {
165
+ const prs = await gitApi.getPullRequests(repositoryId, { sourceRefName, targetRefName, status: PullRequestStatus.Active }, project, undefined, 0, 1);
166
+ if (prs && prs.length > 0) {
167
+ pullRequest = prs[0];
168
+ }
169
+ else {
170
+ return {
171
+ content: [{ type: "text", text: "Pull request created but API returned no data." }],
172
+ };
173
+ }
174
+ }
140
175
  const trimmedPullRequest = trimPullRequest(pullRequest, true);
176
+ if (!trimmedPullRequest) {
177
+ return {
178
+ content: [{ type: "text", text: "Pull request created but API returned no data." }],
179
+ };
180
+ }
141
181
  return {
142
182
  content: [{ type: "text", text: JSON.stringify(trimmedPullRequest, null, 2) }],
143
183
  };
@@ -151,11 +191,14 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
151
191
  }
152
192
  });
153
193
  server.tool(REPO_TOOLS.create_branch, "Create a new branch in the repository.", {
154
- repositoryId: z.string().describe("The ID of the repository where the branch will be created."),
194
+ repositoryId: z
195
+ .string()
196
+ .describe("The ID or name of the repository where the branch will be created. When using a repository name instead of a GUID, the project parameter must also be provided."),
155
197
  branchName: z.string().describe("The name of the new branch to create, e.g., 'feature-branch'."),
156
198
  sourceBranchName: z.string().optional().default("main").describe("The name of the source branch to create the new branch from. Defaults to 'main'."),
157
199
  sourceCommitId: z.string().optional().describe("The commit ID to create the branch from. If not provided, uses the latest commit of the source branch."),
158
- }, async ({ repositoryId, branchName, sourceBranchName, sourceCommitId }) => {
200
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
201
+ }, async ({ repositoryId, branchName, sourceBranchName, sourceCommitId, project }) => {
159
202
  try {
160
203
  const connection = await connectionProvider();
161
204
  const gitApi = await connection.getGitApi();
@@ -164,7 +207,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
164
207
  if (!commitId) {
165
208
  const sourceRefName = `refs/heads/${sourceBranchName}`;
166
209
  try {
167
- const sourceBranch = await gitApi.getRefs(repositoryId, undefined, "heads/", false, false, undefined, false, undefined, sourceBranchName);
210
+ const sourceBranch = await gitApi.getRefs(repositoryId, project, "heads/", false, false, undefined, false, undefined, sourceBranchName);
168
211
  const branch = sourceBranch.find((b) => b.name === sourceRefName);
169
212
  if (!branch || !branch.objectId) {
170
213
  return {
@@ -199,7 +242,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
199
242
  oldObjectId: "0000000000000000000000000000000000000000", // All zeros indicates creating a new ref
200
243
  };
201
244
  try {
202
- const result = await gitApi.updateRefs([refUpdate], repositoryId);
245
+ const result = await gitApi.updateRefs([refUpdate], repositoryId, project);
203
246
  // Check if the branch creation was successful
204
247
  if (result && result.length > 0 && result[0].success) {
205
248
  return {
@@ -245,8 +288,9 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
245
288
  }
246
289
  });
247
290
  server.tool(REPO_TOOLS.update_pull_request, "Update a Pull Request by ID with specified fields, including setting autocomplete with various completion options.", {
248
- repositoryId: z.string().describe("The ID of the repository where the pull request exists."),
291
+ repositoryId: z.string().describe("The ID or name of the repository where the pull request exists. When using a repository name instead of a GUID, the project parameter must also be provided."),
249
292
  pullRequestId: z.number().describe("The ID of the pull request to update."),
293
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
250
294
  title: z.string().optional().describe("The new title for the pull request."),
251
295
  description: z.string().max(4000).optional().describe("The new description for the pull request. Must not be longer than 4000 characters."),
252
296
  isDraft: z.boolean().optional().describe("Whether the pull request should be a draft."),
@@ -261,7 +305,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
261
305
  transitionWorkItems: z.boolean().optional().default(true).describe("Whether to transition associated work items to the next state when the pull request autocompletes. Defaults to true."),
262
306
  bypassReason: z.string().optional().describe("Reason for bypassing branch policies. When provided, branch policies will be automatically bypassed during autocompletion."),
263
307
  labels: z.array(z.string()).optional().describe("Array of label names to replace existing labels on the pull request. This will remove all current labels and add the specified ones."),
264
- }, async ({ repositoryId, pullRequestId, title, description, isDraft, targetRefName, status, autoComplete, mergeStrategy, deleteSourceBranch, transitionWorkItems, bypassReason, labels }) => {
308
+ }, async ({ repositoryId, pullRequestId, project, title, description, isDraft, targetRefName, status, autoComplete, mergeStrategy, deleteSourceBranch, transitionWorkItems, bypassReason, labels, }) => {
265
309
  try {
266
310
  const connection = await connectionProvider();
267
311
  const gitApi = await connection.getGitApi();
@@ -310,25 +354,30 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
310
354
  }
311
355
  // Update labels if provided
312
356
  if (labels) {
313
- const currentLabels = await gitApi.getPullRequestLabels(repositoryId, pullRequestId);
357
+ const currentLabels = await gitApi.getPullRequestLabels(repositoryId, pullRequestId, project);
314
358
  for (const currentLabel of currentLabels) {
315
359
  if (currentLabel.id) {
316
- await gitApi.deletePullRequestLabels(repositoryId, pullRequestId, currentLabel.id);
360
+ await gitApi.deletePullRequestLabels(repositoryId, pullRequestId, currentLabel.id, project);
317
361
  }
318
362
  }
319
363
  for (const label of labels) {
320
- await gitApi.createPullRequestLabel({ name: label }, repositoryId, pullRequestId);
364
+ await gitApi.createPullRequestLabel({ name: label }, repositoryId, pullRequestId, project);
321
365
  }
322
366
  }
323
367
  let updatedPullRequest;
324
368
  if (Object.keys(updateRequest).length > 0) {
325
- updatedPullRequest = await gitApi.updatePullRequest(updateRequest, repositoryId, pullRequestId);
369
+ updatedPullRequest = await gitApi.updatePullRequest(updateRequest, repositoryId, pullRequestId, project);
326
370
  }
327
371
  else {
328
372
  // If only labels were updated, get the current pull request
329
- updatedPullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId);
373
+ updatedPullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, project);
330
374
  }
331
375
  const trimmedUpdatedPullRequest = trimPullRequest(updatedPullRequest, true);
376
+ if (!trimmedUpdatedPullRequest) {
377
+ return {
378
+ content: [{ type: "text", text: "Pull request updated but API returned no data." }],
379
+ };
380
+ }
332
381
  return {
333
382
  content: [{ type: "text", text: JSON.stringify(trimmedUpdatedPullRequest, null, 2) }],
334
383
  };
@@ -342,17 +391,18 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
342
391
  }
343
392
  });
344
393
  server.tool(REPO_TOOLS.update_pull_request_reviewers, "Add or remove reviewers for an existing pull request.", {
345
- repositoryId: z.string().describe("The ID of the repository where the pull request exists."),
394
+ repositoryId: z.string().describe("The ID or name of the repository where the pull request exists. When using a repository name instead of a GUID, the project parameter must also be provided."),
346
395
  pullRequestId: z.number().describe("The ID of the pull request to update."),
347
396
  reviewerIds: z.array(z.string()).describe("List of reviewer ids to add or remove from the pull request."),
348
397
  action: z.enum(["add", "remove"]).describe("Action to perform on the reviewers. Can be 'add' or 'remove'."),
349
- }, async ({ repositoryId, pullRequestId, reviewerIds, action }) => {
398
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
399
+ }, async ({ repositoryId, pullRequestId, reviewerIds, action, project }) => {
350
400
  try {
351
401
  const connection = await connectionProvider();
352
402
  const gitApi = await connection.getGitApi();
353
403
  let updatedPullRequest;
354
404
  if (action === "add") {
355
- updatedPullRequest = await gitApi.createPullRequestReviewers(reviewerIds.map((id) => ({ id: id })), repositoryId, pullRequestId);
405
+ updatedPullRequest = await gitApi.createPullRequestReviewers(reviewerIds.map((id) => ({ id: id })), repositoryId, pullRequestId, project);
356
406
  const trimmedResponse = updatedPullRequest.map((item) => ({
357
407
  displayName: item.displayName,
358
408
  id: item.id,
@@ -367,7 +417,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
367
417
  }
368
418
  else {
369
419
  for (const reviewerId of reviewerIds) {
370
- await gitApi.deletePullRequestReviewer(repositoryId, pullRequestId, reviewerId);
420
+ await gitApi.deletePullRequestReviewer(repositoryId, pullRequestId, reviewerId, project);
371
421
  }
372
422
  return {
373
423
  content: [{ type: "text", text: `Reviewers with IDs ${reviewerIds.join(", ")} removed from pull request ${pullRequestId}.` }],
@@ -417,8 +467,11 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
417
467
  }
418
468
  });
419
469
  server.tool(REPO_TOOLS.list_pull_requests_by_repo_or_project, "Retrieve a list of pull requests for a given repository. Either repositoryId or project must be provided.", {
420
- repositoryId: z.string().optional().describe("The ID of the repository where the pull requests are located."),
421
- project: z.string().optional().describe("The ID of the project where the pull requests are located."),
470
+ repositoryId: z
471
+ .string()
472
+ .optional()
473
+ .describe("The ID or name of the repository where the pull requests are located. When using a repository name instead of a GUID, the project parameter must also be provided."),
474
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID, or to scope the search to a specific project."),
422
475
  top: z.number().default(100).describe("The maximum number of pull requests to return."),
423
476
  skip: z.number().default(0).describe("The number of pull requests to skip."),
424
477
  created_by_me: z.boolean().default(false).describe("Filter pull requests created by the current user."),
@@ -543,9 +596,11 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
543
596
  }
544
597
  });
545
598
  server.tool(REPO_TOOLS.list_pull_request_threads, "Retrieve a list of comment threads for a pull request.", {
546
- repositoryId: z.string().describe("The ID of the repository where the pull request is located."),
599
+ repositoryId: z
600
+ .string()
601
+ .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
547
602
  pullRequestId: z.number().describe("The ID of the pull request for which to retrieve threads."),
548
- project: z.string().optional().describe("Project ID or project name (optional)"),
603
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
549
604
  iteration: z.number().optional().describe("The iteration ID for which to retrieve threads. Optional, defaults to the latest iteration."),
550
605
  baseIteration: z.number().optional().describe("The base iteration ID for which to retrieve threads. Optional, defaults to the latest base iteration."),
551
606
  top: z.number().default(100).describe("The maximum number of threads to return after filtering."),
@@ -601,10 +656,12 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
601
656
  }
602
657
  });
603
658
  server.tool(REPO_TOOLS.list_pull_request_thread_comments, "Retrieve a list of comments in a pull request thread.", {
604
- repositoryId: z.string().describe("The ID of the repository where the pull request is located."),
659
+ repositoryId: z
660
+ .string()
661
+ .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
605
662
  pullRequestId: z.number().describe("The ID of the pull request for which to retrieve thread comments."),
606
663
  threadId: z.number().describe("The ID of the thread for which to retrieve comments."),
607
- project: z.string().optional().describe("Project ID or project name (optional)"),
664
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
608
665
  top: z.number().default(100).describe("The maximum number of comments to return."),
609
666
  skip: z.number().default(0).describe("The number of comments to skip."),
610
667
  fullResponse: z.boolean().optional().default(false).describe("Return full comment JSON response instead of trimmed data."),
@@ -635,14 +692,17 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
635
692
  }
636
693
  });
637
694
  server.tool(REPO_TOOLS.list_branches_by_repo, "Retrieve a list of branches for a given repository.", {
638
- repositoryId: z.string().describe("The ID of the repository where the branches are located."),
695
+ repositoryId: z
696
+ .string()
697
+ .describe("The ID or name of the repository where the branches are located. When using a repository name instead of a GUID, the project parameter must also be provided."),
639
698
  top: z.number().default(100).describe("The maximum number of branches to return. Defaults to 100."),
640
699
  filterContains: z.string().optional().describe("Filter to find branches that contain this string in their name."),
641
- }, async ({ repositoryId, top, filterContains }) => {
700
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
701
+ }, async ({ repositoryId, top, filterContains, project }) => {
642
702
  try {
643
703
  const connection = await connectionProvider();
644
704
  const gitApi = await connection.getGitApi();
645
- const branches = await gitApi.getRefs(repositoryId, undefined, "heads/", undefined, undefined, undefined, undefined, undefined, filterContains);
705
+ const branches = await gitApi.getRefs(repositoryId, project, "heads/", undefined, undefined, undefined, undefined, undefined, filterContains);
646
706
  const filteredBranches = branchesFilterOutIrrelevantProperties(branches, top);
647
707
  return {
648
708
  content: [{ type: "text", text: JSON.stringify(filteredBranches, null, 2) }],
@@ -657,14 +717,17 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
657
717
  }
658
718
  });
659
719
  server.tool(REPO_TOOLS.list_my_branches_by_repo, "Retrieve a list of my branches for a given repository Id.", {
660
- repositoryId: z.string().describe("The ID of the repository where the branches are located."),
720
+ repositoryId: z
721
+ .string()
722
+ .describe("The ID or name of the repository where the branches are located. When using a repository name instead of a GUID, the project parameter must also be provided."),
661
723
  top: z.number().default(100).describe("The maximum number of branches to return."),
662
724
  filterContains: z.string().optional().describe("Filter to find branches that contain this string in their name."),
663
- }, async ({ repositoryId, top, filterContains }) => {
725
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
726
+ }, async ({ repositoryId, top, filterContains, project }) => {
664
727
  try {
665
728
  const connection = await connectionProvider();
666
729
  const gitApi = await connection.getGitApi();
667
- const branches = await gitApi.getRefs(repositoryId, undefined, "heads/", undefined, undefined, true, undefined, undefined, filterContains);
730
+ const branches = await gitApi.getRefs(repositoryId, project, "heads/", undefined, undefined, true, undefined, undefined, filterContains);
668
731
  const filteredBranches = branchesFilterOutIrrelevantProperties(branches, top);
669
732
  return {
670
733
  content: [{ type: "text", text: JSON.stringify(filteredBranches, null, 2) }],
@@ -706,13 +769,14 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
706
769
  }
707
770
  });
708
771
  server.tool(REPO_TOOLS.get_branch_by_name, "Get a branch by its name.", {
709
- repositoryId: z.string().describe("The ID of the repository where the branch is located."),
772
+ repositoryId: z.string().describe("The ID or name of the repository where the branch is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
710
773
  branchName: z.string().describe("The name of the branch to retrieve, e.g., 'main' or 'feature-branch'."),
711
- }, async ({ repositoryId, branchName }) => {
774
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
775
+ }, async ({ repositoryId, branchName, project }) => {
712
776
  try {
713
777
  const connection = await connectionProvider();
714
778
  const gitApi = await connection.getGitApi();
715
- const branches = await gitApi.getRefs(repositoryId, undefined, "heads/", false, false, undefined, false, undefined, branchName);
779
+ const branches = await gitApi.getRefs(repositoryId, project, "heads/", false, false, undefined, false, undefined, branchName);
716
780
  const branch = branches.find((branch) => branch.name === `refs/heads/${branchName}` || branch.name === branchName);
717
781
  if (!branch) {
718
782
  return {
@@ -738,15 +802,18 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
738
802
  }
739
803
  });
740
804
  server.tool(REPO_TOOLS.get_pull_request_by_id, "Get a pull request by its ID.", {
741
- repositoryId: z.string().describe("The ID of the repository where the pull request is located."),
805
+ repositoryId: z
806
+ .string()
807
+ .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
742
808
  pullRequestId: z.number().describe("The ID of the pull request to retrieve."),
809
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
743
810
  includeWorkItemRefs: z.boolean().optional().default(false).describe("Whether to reference work items associated with the pull request."),
744
811
  includeLabels: z.boolean().optional().default(false).describe("Whether to include a summary of labels in the response."),
745
- }, async ({ repositoryId, pullRequestId, includeWorkItemRefs, includeLabels }) => {
812
+ }, async ({ repositoryId, pullRequestId, project, includeWorkItemRefs, includeLabels }) => {
746
813
  try {
747
814
  const connection = await connectionProvider();
748
815
  const gitApi = await connection.getGitApi();
749
- const pullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, undefined, undefined, undefined, undefined, undefined, includeWorkItemRefs);
816
+ const pullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, project, undefined, undefined, undefined, undefined, includeWorkItemRefs);
750
817
  if (includeLabels) {
751
818
  try {
752
819
  const projectId = pullRequest.repository?.project?.id;
@@ -789,11 +856,13 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
789
856
  }
790
857
  });
791
858
  server.tool(REPO_TOOLS.reply_to_comment, "Replies to a specific comment on a pull request.", {
792
- repositoryId: z.string().describe("The ID of the repository where the pull request is located."),
859
+ repositoryId: z
860
+ .string()
861
+ .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
793
862
  pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."),
794
863
  threadId: z.number().describe("The ID of the thread to which the comment will be added."),
795
864
  content: z.string().describe("The content of the comment to be added."),
796
- project: z.string().optional().describe("Project ID or project name (optional)"),
865
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
797
866
  fullResponse: z.boolean().optional().default(false).describe("Return full comment JSON response instead of a simple confirmation message."),
798
867
  }, async ({ repositoryId, pullRequestId, threadId, content, project, fullResponse }) => {
799
868
  try {
@@ -825,10 +894,12 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
825
894
  }
826
895
  });
827
896
  server.tool(REPO_TOOLS.create_pull_request_thread, "Creates a new comment thread on a pull request.", {
828
- repositoryId: z.string().describe("The ID of the repository where the pull request is located."),
897
+ repositoryId: z
898
+ .string()
899
+ .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
829
900
  pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."),
830
901
  content: z.string().describe("The content of the comment to be added."),
831
- project: z.string().optional().describe("Project ID or project name (optional)"),
902
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
832
903
  filePath: z.string().optional().describe("The path of the file where the comment thread will be created. (optional)"),
833
904
  status: z
834
905
  .enum(getEnumKeys(CommentThreadStatus))
@@ -939,10 +1010,12 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
939
1010
  }
940
1011
  });
941
1012
  server.tool(REPO_TOOLS.update_pull_request_thread, "Updates an existing comment thread on a pull request.", {
942
- repositoryId: z.string().describe("The ID of the repository where the pull request is located."),
1013
+ repositoryId: z
1014
+ .string()
1015
+ .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
943
1016
  pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."),
944
1017
  threadId: z.number().describe("The ID of the thread to update."),
945
- project: z.string().optional().describe("Project ID or project name (optional)"),
1018
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
946
1019
  status: z
947
1020
  .enum(getEnumKeys(CommentThreadStatus))
948
1021
  .optional()
@@ -1149,5 +1222,101 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1149
1222
  };
1150
1223
  }
1151
1224
  });
1225
+ server.tool(REPO_TOOLS.vote_pull_request, "Cast a vote on a pull request. Automatically adds the current user as a reviewer if they are not already one.", {
1226
+ repositoryId: z.string().describe("The ID or name of the repository. When using a repository name instead of a GUID, the project parameter must also be provided."),
1227
+ pullRequestId: z.number().describe("The ID of the pull request."),
1228
+ vote: z.enum(["Approved", "ApprovedWithSuggestions", "NoVote", "WaitingForAuthor", "Rejected"]).describe("The vote to cast: Approved(10), Suggestions(5), None(0), Waiting(-5), Rejected(-10)."),
1229
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
1230
+ }, async ({ repositoryId, pullRequestId, vote, project }) => {
1231
+ const connection = await connectionProvider();
1232
+ const gitApi = await connection.getGitApi();
1233
+ const userDetails = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
1234
+ const userId = userDetails.authenticatedUser.id;
1235
+ if (!userId) {
1236
+ throw new Error("Could not determine authenticated user ID.");
1237
+ }
1238
+ const voteMap = {
1239
+ Approved: 10,
1240
+ ApprovedWithSuggestions: 5,
1241
+ NoVote: 0,
1242
+ WaitingForAuthor: -5,
1243
+ Rejected: -10,
1244
+ };
1245
+ await gitApi.createPullRequestReviewer({ vote: voteMap[vote], id: userId }, repositoryId, pullRequestId, userId, project);
1246
+ return {
1247
+ content: [
1248
+ {
1249
+ type: "text",
1250
+ text: `Successfully cast vote '${vote}' on PR #${pullRequestId}.`,
1251
+ },
1252
+ ],
1253
+ };
1254
+ });
1255
+ server.tool(REPO_TOOLS.list_directory, "List files and folders in a directory within a repository. Useful for exploring the structure of a codebase or finding related files.", {
1256
+ repositoryId: z.string().describe("The ID or name of the repository."),
1257
+ path: z.string().optional().default("/").describe("The directory path to list (e.g., '/src' or '/src/components'). Defaults to repository root."),
1258
+ project: z.string().optional().describe("Project ID or name. Required if repositoryId is a name rather than a GUID."),
1259
+ version: z.string().optional().describe("The version identifier - branch name (e.g., 'main'), tag name, or commit SHA. Defaults to the repository's default branch."),
1260
+ versionType: z.enum(["Branch", "Commit", "Tag"]).optional().default("Branch").describe("The type of version identifier: 'Branch', 'Commit', or 'Tag'. Defaults to 'Branch'."),
1261
+ recursive: z.boolean().optional().default(false).describe("Whether to list items recursively. Defaults to false."),
1262
+ recursionDepth: z.number().optional().default(1).describe("Maximum depth for recursive listing (1-10). Only applies when recursive is true. Defaults to 1."),
1263
+ }, async ({ repositoryId, path, project, version, versionType, recursive, recursionDepth }) => {
1264
+ try {
1265
+ const connection = await connectionProvider();
1266
+ const gitApi = await connection.getGitApi();
1267
+ const versionDescriptor = buildVersionDescriptor(version, versionType);
1268
+ const clampedDepth = Math.min(Math.max(recursionDepth || 1, 1), 10);
1269
+ let recursionType = VersionControlRecursionType.OneLevel;
1270
+ if (recursive) {
1271
+ recursionType = VersionControlRecursionType.Full;
1272
+ }
1273
+ const items = await gitApi.getItems(repositoryId, project, path, recursionType, true, false, false, false, versionDescriptor);
1274
+ if (!items || items.length === 0) {
1275
+ return {
1276
+ content: [{ type: "text", text: `No items found at path: ${path}` }],
1277
+ };
1278
+ }
1279
+ let filteredItems = items;
1280
+ if (recursive && clampedDepth < 10) {
1281
+ const basePath = path === "/" ? "" : path;
1282
+ const baseDepth = basePath.split("/").filter((p) => p).length;
1283
+ filteredItems = items.filter((item) => {
1284
+ if (!item.path)
1285
+ return false;
1286
+ const itemDepth = item.path.split("/").filter((p) => p).length;
1287
+ return itemDepth <= baseDepth + clampedDepth;
1288
+ });
1289
+ }
1290
+ const formattedItems = filteredItems.map((item) => ({
1291
+ path: item.path,
1292
+ isFolder: item.isFolder,
1293
+ gitObjectType: item.gitObjectType,
1294
+ commitId: item.commitId,
1295
+ contentMetadata: item.contentMetadata
1296
+ ? {
1297
+ contentType: item.contentMetadata.contentType,
1298
+ fileName: item.contentMetadata.fileName,
1299
+ }
1300
+ : undefined,
1301
+ }));
1302
+ const response = {
1303
+ count: formattedItems.length,
1304
+ path: path,
1305
+ recursive: recursive,
1306
+ recursionDepth: recursive ? clampedDepth : undefined,
1307
+ items: formattedItems,
1308
+ };
1309
+ return {
1310
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }],
1311
+ };
1312
+ }
1313
+ catch (error) {
1314
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
1315
+ return {
1316
+ content: [{ type: "text", text: `Error listing directory: ${errorMessage}` }],
1317
+ isError: true,
1318
+ };
1319
+ }
1320
+ });
1152
1321
  }
1153
1322
  export { REPO_TOOLS, configureRepoTools };
@@ -76,29 +76,47 @@ function configureTestPlanTools(server, _, connectionProvider) {
76
76
  parentSuiteId: z.number().describe("ID of the parent suite under which the new suite will be created, if not given by user this can be id of a root suite of the test plan"),
77
77
  name: z.string().describe("Name of the child test suite"),
78
78
  }, async ({ project, planId, parentSuiteId, name }) => {
79
- try {
80
- const connection = await connectionProvider();
81
- const testPlanApi = await connection.getTestPlanApi();
82
- const testSuiteToCreate = {
83
- name,
84
- parentSuite: {
85
- id: parentSuiteId,
86
- name: "",
87
- },
88
- suiteType: 2,
89
- };
90
- const createdTestSuite = await testPlanApi.createTestSuite(testSuiteToCreate, project, planId);
91
- return {
92
- content: [{ type: "text", text: JSON.stringify(createdTestSuite, null, 2) }],
93
- };
94
- }
95
- catch (error) {
96
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
97
- return {
98
- content: [{ type: "text", text: `Error creating test suite: ${errorMessage}` }],
99
- isError: true,
100
- };
79
+ const maxRetries = 5;
80
+ const baseDelay = 500; // milliseconds
81
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
82
+ try {
83
+ const connection = await connectionProvider();
84
+ const testPlanApi = await connection.getTestPlanApi();
85
+ const testSuiteToCreate = {
86
+ name,
87
+ parentSuite: {
88
+ id: parentSuiteId,
89
+ name: "",
90
+ },
91
+ suiteType: 2,
92
+ };
93
+ const createdTestSuite = await testPlanApi.createTestSuite(testSuiteToCreate, project, planId);
94
+ return {
95
+ content: [{ type: "text", text: JSON.stringify(createdTestSuite, null, 2) }],
96
+ };
97
+ }
98
+ catch (error) {
99
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
100
+ // Check if it's a concurrency conflict error
101
+ const isConcurrencyError = errorMessage.includes("TF26071") || errorMessage.includes("got update") || errorMessage.includes("changed by someone else");
102
+ // If it's a concurrency error and we have retries left, wait and retry
103
+ if (isConcurrencyError && attempt < maxRetries) {
104
+ const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 200; // Exponential backoff with jitter
105
+ await new Promise((resolve) => setTimeout(resolve, delay));
106
+ continue; // Retry
107
+ }
108
+ // If not a concurrency error or out of retries, return error
109
+ return {
110
+ content: [{ type: "text", text: `Error creating test suite: ${errorMessage}` }],
111
+ isError: true,
112
+ };
113
+ }
101
114
  }
115
+ // This should never be reached, but TypeScript requires a return value
116
+ return {
117
+ content: [{ type: "text", text: "Error creating test suite: Maximum retries exceeded" }],
118
+ isError: true,
119
+ };
102
120
  });
103
121
  server.tool(Test_Plan_Tools.add_test_cases_to_suite, "Adds existing test cases to a test suite.", {
104
122
  project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
@@ -257,16 +275,48 @@ function configureTestPlanTools(server, _, connectionProvider) {
257
275
  };
258
276
  }
259
277
  });
260
- server.tool(Test_Plan_Tools.test_results_from_build_id, "Gets a list of test results for a given project and build ID.", {
278
+ server.tool(Test_Plan_Tools.test_results_from_build_id, "Gets a list of test results for a given project and build ID. Can filter by test outcome (e.g. Failed, Passed, Aborted). Returns test case titles, error messages, stack traces, and outcomes. Efficiently handles builds with large numbers of test runs.", {
261
279
  project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
262
280
  buildid: z.number().describe("The ID of the build."),
263
- }, async ({ project, buildid }) => {
281
+ outcomes: z.array(z.string()).optional().describe("Filter results by test outcome, e.g. ['Failed', 'Passed', 'Aborted']."),
282
+ }, async ({ project, buildid, outcomes }) => {
264
283
  try {
265
284
  const connection = await connectionProvider();
266
- const coreApi = await connection.getTestResultsApi();
267
- const testResults = await coreApi.getTestResultDetailsForBuild(project, buildid);
285
+ const testResultsApi = await connection.getTestResultsApi();
286
+ // Build filter expression for outcomes if specified
287
+ const outcomeFilter = outcomes?.map((o) => `Outcome eq '${o}'`).join(" or ");
288
+ // Fetch test result details for the build in a single API call
289
+ // This is more efficient than getTestRuns + getTestResults per run,
290
+ // especially for builds with many test runs (e.g., cloud testing with one run per test case)
291
+ const testResultDetails = await testResultsApi.getTestResultDetailsForBuild(project, buildid, undefined, // publishContext
292
+ undefined, // groupBy
293
+ outcomeFilter, // filter by outcome
294
+ undefined, // orderby
295
+ true // shouldIncludeResults - get individual test results, not just aggregates
296
+ );
297
+ // Extract individual test results from the grouped response
298
+ const allResults = [];
299
+ if (testResultDetails.resultsForGroup) {
300
+ for (const group of testResultDetails.resultsForGroup) {
301
+ if (group.results) {
302
+ allResults.push(...group.results);
303
+ }
304
+ }
305
+ }
306
+ // Format results to extract useful fields
307
+ const formattedResults = allResults.map((r) => ({
308
+ id: r.id,
309
+ testCaseTitle: r.testCaseTitle,
310
+ outcome: r.outcome,
311
+ errorMessage: r.errorMessage,
312
+ stackTrace: r.stackTrace,
313
+ automatedTestName: r.automatedTestName,
314
+ automatedTestStorage: r.automatedTestStorage,
315
+ durationInMs: r.durationInMs,
316
+ runId: r.testRun?.id,
317
+ }));
268
318
  return {
269
- content: [{ type: "text", text: JSON.stringify(testResults, null, 2) }],
319
+ content: [{ type: "text", text: JSON.stringify(formattedResults, null, 2) }],
270
320
  };
271
321
  }
272
322
  catch (error) {