@azure-devops/mcp 2.5.0 → 2.6.0-nightly.20260419

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,9 +1,9 @@
1
1
  // Copyright (c) Microsoft Corporation.
2
2
  // Licensed under the MIT License.
3
- import { PullRequestStatus, GitVersionType, GitPullRequestQueryType, CommentThreadStatus, GitPullRequestMergeStrategy, VersionControlRecursionType, } from "azure-devops-node-api/interfaces/GitInterfaces.js";
3
+ import { PullRequestStatus, GitVersionType, GitPullRequestQueryType, CommentThreadStatus, GitPullRequestMergeStrategy, VersionControlChangeType, VersionControlRecursionType, } from "azure-devops-node-api/interfaces/GitInterfaces.js";
4
4
  import { z } from "zod";
5
5
  import { getCurrentUserDetails, getUserIdFromEmail } from "./auth.js";
6
- import { getEnumKeys } from "../utils.js";
6
+ import { getEnumKeys, streamToString } from "../utils.js";
7
7
  const REPO_TOOLS = {
8
8
  list_repos_by_project: "repo_list_repos_by_project",
9
9
  list_pull_requests_by_repo_or_project: "repo_list_pull_requests_by_repo_or_project",
@@ -14,6 +14,7 @@ const REPO_TOOLS = {
14
14
  get_repo_by_name_or_id: "repo_get_repo_by_name_or_id",
15
15
  get_branch_by_name: "repo_get_branch_by_name",
16
16
  get_pull_request_by_id: "repo_get_pull_request_by_id",
17
+ get_pull_request_changes: "repo_get_pull_request_changes",
17
18
  create_pull_request: "repo_create_pull_request",
18
19
  create_branch: "repo_create_branch",
19
20
  update_pull_request: "repo_update_pull_request",
@@ -25,6 +26,7 @@ const REPO_TOOLS = {
25
26
  list_pull_requests_by_commits: "repo_list_pull_requests_by_commits",
26
27
  vote_pull_request: "repo_vote_pull_request",
27
28
  list_directory: "repo_list_directory",
29
+ get_file_content: "repo_get_file_content",
28
30
  };
29
31
  function branchesFilterOutIrrelevantProperties(branches, top) {
30
32
  return branches
@@ -86,6 +88,9 @@ function filterReposByName(repositories, repoNameFilter) {
86
88
  return filteredByName;
87
89
  }
88
90
  function trimPullRequest(pr, includeDescription = false) {
91
+ if (!pr) {
92
+ return null;
93
+ }
89
94
  return {
90
95
  pullRequestId: pr.pullRequestId,
91
96
  codeReviewId: pr.codeReviewId,
@@ -122,16 +127,19 @@ function buildVersionDescriptor(version, versionType) {
122
127
  }
123
128
  function configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider) {
124
129
  server.tool(REPO_TOOLS.create_pull_request, "Create a new pull request.", {
125
- repositoryId: z.string().describe("The ID of the repository where the pull request will be created."),
130
+ repositoryId: z
131
+ .string()
132
+ .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."),
126
133
  sourceRefName: z.string().describe("The source branch name for the pull request, e.g., 'refs/heads/feature-branch'."),
127
134
  targetRefName: z.string().describe("The target branch name for the pull request, e.g., 'refs/heads/main'."),
128
135
  title: z.string().describe("The title of the pull request."),
129
136
  description: z.string().max(4000).optional().describe("The description of the pull request. Must not be longer than 4000 characters. Optional."),
130
137
  isDraft: z.boolean().optional().default(false).describe("Indicates whether the pull request is a draft. Defaults to false."),
138
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
131
139
  workItems: z.string().optional().describe("Work item IDs to associate with the pull request, space-separated."),
132
140
  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."),
133
141
  labels: z.array(z.string()).optional().describe("Array of label names to add to the pull request after creation."),
134
- }, async ({ repositoryId, sourceRefName, targetRefName, title, description, isDraft, workItems, forkSourceRepositoryId, labels }) => {
142
+ }, async ({ repositoryId, sourceRefName, targetRefName, title, description, isDraft, project, workItems, forkSourceRepositoryId, labels }) => {
135
143
  try {
136
144
  const connection = await connectionProvider();
137
145
  const gitApi = await connection.getGitApi();
@@ -154,9 +162,9 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
154
162
  forkSource,
155
163
  labels: labelDefinitions,
156
164
  supportsIterations: true,
157
- }, repositoryId);
165
+ }, repositoryId, project);
158
166
  if (!pullRequest) {
159
- const prs = await gitApi.getPullRequests(repositoryId, { sourceRefName, targetRefName, status: PullRequestStatus.Active }, undefined, undefined, 0, 1);
167
+ const prs = await gitApi.getPullRequests(repositoryId, { sourceRefName, targetRefName, status: PullRequestStatus.Active }, project, undefined, 0, 1);
160
168
  if (prs && prs.length > 0) {
161
169
  pullRequest = prs[0];
162
170
  }
@@ -167,6 +175,11 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
167
175
  }
168
176
  }
169
177
  const trimmedPullRequest = trimPullRequest(pullRequest, true);
178
+ if (!trimmedPullRequest) {
179
+ return {
180
+ content: [{ type: "text", text: "Pull request created but API returned no data." }],
181
+ };
182
+ }
170
183
  return {
171
184
  content: [{ type: "text", text: JSON.stringify(trimmedPullRequest, null, 2) }],
172
185
  };
@@ -180,11 +193,14 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
180
193
  }
181
194
  });
182
195
  server.tool(REPO_TOOLS.create_branch, "Create a new branch in the repository.", {
183
- repositoryId: z.string().describe("The ID of the repository where the branch will be created."),
196
+ repositoryId: z
197
+ .string()
198
+ .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."),
184
199
  branchName: z.string().describe("The name of the new branch to create, e.g., 'feature-branch'."),
185
200
  sourceBranchName: z.string().optional().default("main").describe("The name of the source branch to create the new branch from. Defaults to 'main'."),
186
201
  sourceCommitId: z.string().optional().describe("The commit ID to create the branch from. If not provided, uses the latest commit of the source branch."),
187
- }, async ({ repositoryId, branchName, sourceBranchName, sourceCommitId }) => {
202
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
203
+ }, async ({ repositoryId, branchName, sourceBranchName, sourceCommitId, project }) => {
188
204
  try {
189
205
  const connection = await connectionProvider();
190
206
  const gitApi = await connection.getGitApi();
@@ -193,7 +209,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
193
209
  if (!commitId) {
194
210
  const sourceRefName = `refs/heads/${sourceBranchName}`;
195
211
  try {
196
- const sourceBranch = await gitApi.getRefs(repositoryId, undefined, "heads/", false, false, undefined, false, undefined, sourceBranchName);
212
+ const sourceBranch = await gitApi.getRefs(repositoryId, project, "heads/", false, false, undefined, false, undefined, sourceBranchName);
197
213
  const branch = sourceBranch.find((b) => b.name === sourceRefName);
198
214
  if (!branch || !branch.objectId) {
199
215
  return {
@@ -228,7 +244,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
228
244
  oldObjectId: "0000000000000000000000000000000000000000", // All zeros indicates creating a new ref
229
245
  };
230
246
  try {
231
- const result = await gitApi.updateRefs([refUpdate], repositoryId);
247
+ const result = await gitApi.updateRefs([refUpdate], repositoryId, project);
232
248
  // Check if the branch creation was successful
233
249
  if (result && result.length > 0 && result[0].success) {
234
250
  return {
@@ -274,8 +290,9 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
274
290
  }
275
291
  });
276
292
  server.tool(REPO_TOOLS.update_pull_request, "Update a Pull Request by ID with specified fields, including setting autocomplete with various completion options.", {
277
- repositoryId: z.string().describe("The ID of the repository where the pull request exists."),
278
- pullRequestId: z.number().describe("The ID of the pull request to update."),
293
+ 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."),
294
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request to update."),
295
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
279
296
  title: z.string().optional().describe("The new title for the pull request."),
280
297
  description: z.string().max(4000).optional().describe("The new description for the pull request. Must not be longer than 4000 characters."),
281
298
  isDraft: z.boolean().optional().describe("Whether the pull request should be a draft."),
@@ -290,7 +307,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
290
307
  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."),
291
308
  bypassReason: z.string().optional().describe("Reason for bypassing branch policies. When provided, branch policies will be automatically bypassed during autocompletion."),
292
309
  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."),
293
- }, async ({ repositoryId, pullRequestId, title, description, isDraft, targetRefName, status, autoComplete, mergeStrategy, deleteSourceBranch, transitionWorkItems, bypassReason, labels }) => {
310
+ }, async ({ repositoryId, pullRequestId, project, title, description, isDraft, targetRefName, status, autoComplete, mergeStrategy, deleteSourceBranch, transitionWorkItems, bypassReason, labels, }) => {
294
311
  try {
295
312
  const connection = await connectionProvider();
296
313
  const gitApi = await connection.getGitApi();
@@ -339,25 +356,30 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
339
356
  }
340
357
  // Update labels if provided
341
358
  if (labels) {
342
- const currentLabels = await gitApi.getPullRequestLabels(repositoryId, pullRequestId);
359
+ const currentLabels = await gitApi.getPullRequestLabels(repositoryId, pullRequestId, project);
343
360
  for (const currentLabel of currentLabels) {
344
361
  if (currentLabel.id) {
345
- await gitApi.deletePullRequestLabels(repositoryId, pullRequestId, currentLabel.id);
362
+ await gitApi.deletePullRequestLabels(repositoryId, pullRequestId, currentLabel.id, project);
346
363
  }
347
364
  }
348
365
  for (const label of labels) {
349
- await gitApi.createPullRequestLabel({ name: label }, repositoryId, pullRequestId);
366
+ await gitApi.createPullRequestLabel({ name: label }, repositoryId, pullRequestId, project);
350
367
  }
351
368
  }
352
369
  let updatedPullRequest;
353
370
  if (Object.keys(updateRequest).length > 0) {
354
- updatedPullRequest = await gitApi.updatePullRequest(updateRequest, repositoryId, pullRequestId);
371
+ updatedPullRequest = await gitApi.updatePullRequest(updateRequest, repositoryId, pullRequestId, project);
355
372
  }
356
373
  else {
357
374
  // If only labels were updated, get the current pull request
358
- updatedPullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId);
375
+ updatedPullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, project);
359
376
  }
360
377
  const trimmedUpdatedPullRequest = trimPullRequest(updatedPullRequest, true);
378
+ if (!trimmedUpdatedPullRequest) {
379
+ return {
380
+ content: [{ type: "text", text: "Pull request updated but API returned no data." }],
381
+ };
382
+ }
361
383
  return {
362
384
  content: [{ type: "text", text: JSON.stringify(trimmedUpdatedPullRequest, null, 2) }],
363
385
  };
@@ -371,17 +393,18 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
371
393
  }
372
394
  });
373
395
  server.tool(REPO_TOOLS.update_pull_request_reviewers, "Add or remove reviewers for an existing pull request.", {
374
- repositoryId: z.string().describe("The ID of the repository where the pull request exists."),
375
- pullRequestId: z.number().describe("The ID of the pull request to update."),
396
+ 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."),
397
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request to update."),
376
398
  reviewerIds: z.array(z.string()).describe("List of reviewer ids to add or remove from the pull request."),
377
399
  action: z.enum(["add", "remove"]).describe("Action to perform on the reviewers. Can be 'add' or 'remove'."),
378
- }, async ({ repositoryId, pullRequestId, reviewerIds, action }) => {
400
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
401
+ }, async ({ repositoryId, pullRequestId, reviewerIds, action, project }) => {
379
402
  try {
380
403
  const connection = await connectionProvider();
381
404
  const gitApi = await connection.getGitApi();
382
405
  let updatedPullRequest;
383
406
  if (action === "add") {
384
- updatedPullRequest = await gitApi.createPullRequestReviewers(reviewerIds.map((id) => ({ id: id })), repositoryId, pullRequestId);
407
+ updatedPullRequest = await gitApi.createPullRequestReviewers(reviewerIds.map((id) => ({ id: id })), repositoryId, pullRequestId, project);
385
408
  const trimmedResponse = updatedPullRequest.map((item) => ({
386
409
  displayName: item.displayName,
387
410
  id: item.id,
@@ -396,7 +419,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
396
419
  }
397
420
  else {
398
421
  for (const reviewerId of reviewerIds) {
399
- await gitApi.deletePullRequestReviewer(repositoryId, pullRequestId, reviewerId);
422
+ await gitApi.deletePullRequestReviewer(repositoryId, pullRequestId, reviewerId, project);
400
423
  }
401
424
  return {
402
425
  content: [{ type: "text", text: `Reviewers with IDs ${reviewerIds.join(", ")} removed from pull request ${pullRequestId}.` }],
@@ -413,8 +436,8 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
413
436
  });
414
437
  server.tool(REPO_TOOLS.list_repos_by_project, "Retrieve a list of repositories for a given project", {
415
438
  project: z.string().describe("The name or ID of the Azure DevOps project."),
416
- top: z.number().default(100).describe("The maximum number of repositories to return."),
417
- skip: z.number().default(0).describe("The number of repositories to skip. Defaults to 0."),
439
+ top: z.coerce.number().default(100).describe("The maximum number of repositories to return."),
440
+ skip: z.coerce.number().default(0).describe("The number of repositories to skip. Defaults to 0."),
418
441
  repoNameFilter: z.string().optional().describe("Optional filter to search for repositories by name. If provided, only repositories with names containing this string will be returned."),
419
442
  }, async ({ project, top, skip, repoNameFilter }) => {
420
443
  try {
@@ -446,10 +469,13 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
446
469
  }
447
470
  });
448
471
  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.", {
449
- repositoryId: z.string().optional().describe("The ID of the repository where the pull requests are located."),
450
- project: z.string().optional().describe("The ID of the project where the pull requests are located."),
451
- top: z.number().default(100).describe("The maximum number of pull requests to return."),
452
- skip: z.number().default(0).describe("The number of pull requests to skip."),
472
+ repositoryId: z
473
+ .string()
474
+ .optional()
475
+ .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."),
476
+ 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."),
477
+ top: z.coerce.number().default(100).describe("The maximum number of pull requests to return."),
478
+ skip: z.coerce.number().default(0).describe("The number of pull requests to skip."),
453
479
  created_by_me: z.boolean().default(false).describe("Filter pull requests created by the current user."),
454
480
  created_by_user: z.string().optional().describe("Filter pull requests created by a specific user (provide email or unique name). Takes precedence over created_by_me if both are provided."),
455
481
  i_am_reviewer: z.boolean().default(false).describe("Filter pull requests where the current user is a reviewer."),
@@ -572,13 +598,15 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
572
598
  }
573
599
  });
574
600
  server.tool(REPO_TOOLS.list_pull_request_threads, "Retrieve a list of comment threads for a pull request.", {
575
- repositoryId: z.string().describe("The ID of the repository where the pull request is located."),
576
- pullRequestId: z.number().describe("The ID of the pull request for which to retrieve threads."),
577
- project: z.string().optional().describe("Project ID or project name (optional)"),
578
- iteration: z.number().optional().describe("The iteration ID for which to retrieve threads. Optional, defaults to the latest iteration."),
579
- baseIteration: z.number().optional().describe("The base iteration ID for which to retrieve threads. Optional, defaults to the latest base iteration."),
580
- top: z.number().default(100).describe("The maximum number of threads to return after filtering."),
581
- skip: z.number().default(0).describe("The number of threads to skip after filtering."),
601
+ repositoryId: z
602
+ .string()
603
+ .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."),
604
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request for which to retrieve threads."),
605
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
606
+ iteration: z.coerce.number().min(1).optional().describe("The iteration ID for which to retrieve threads. Optional, defaults to the latest iteration."),
607
+ baseIteration: z.coerce.number().min(1).optional().describe("The base iteration ID for which to retrieve threads. Optional, defaults to the latest base iteration."),
608
+ top: z.coerce.number().default(100).describe("The maximum number of threads to return after filtering."),
609
+ skip: z.coerce.number().default(0).describe("The number of threads to skip after filtering."),
582
610
  fullResponse: z.boolean().optional().default(false).describe("Return full thread JSON response instead of trimmed data."),
583
611
  status: z
584
612
  .enum(getEnumKeys(CommentThreadStatus))
@@ -630,12 +658,14 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
630
658
  }
631
659
  });
632
660
  server.tool(REPO_TOOLS.list_pull_request_thread_comments, "Retrieve a list of comments in a pull request thread.", {
633
- repositoryId: z.string().describe("The ID of the repository where the pull request is located."),
634
- pullRequestId: z.number().describe("The ID of the pull request for which to retrieve thread comments."),
635
- threadId: z.number().describe("The ID of the thread for which to retrieve comments."),
636
- project: z.string().optional().describe("Project ID or project name (optional)"),
637
- top: z.number().default(100).describe("The maximum number of comments to return."),
638
- skip: z.number().default(0).describe("The number of comments to skip."),
661
+ repositoryId: z
662
+ .string()
663
+ .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."),
664
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request for which to retrieve thread comments."),
665
+ threadId: z.coerce.number().min(1).describe("The ID of the thread for which to retrieve comments."),
666
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
667
+ top: z.coerce.number().default(100).describe("The maximum number of comments to return."),
668
+ skip: z.coerce.number().default(0).describe("The number of comments to skip."),
639
669
  fullResponse: z.boolean().optional().default(false).describe("Return full comment JSON response instead of trimmed data."),
640
670
  }, async ({ repositoryId, pullRequestId, threadId, project, top, skip, fullResponse }) => {
641
671
  try {
@@ -664,14 +694,17 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
664
694
  }
665
695
  });
666
696
  server.tool(REPO_TOOLS.list_branches_by_repo, "Retrieve a list of branches for a given repository.", {
667
- repositoryId: z.string().describe("The ID of the repository where the branches are located."),
668
- top: z.number().default(100).describe("The maximum number of branches to return. Defaults to 100."),
697
+ repositoryId: z
698
+ .string()
699
+ .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."),
700
+ top: z.coerce.number().default(100).describe("The maximum number of branches to return. Defaults to 100."),
669
701
  filterContains: z.string().optional().describe("Filter to find branches that contain this string in their name."),
670
- }, async ({ repositoryId, top, filterContains }) => {
702
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
703
+ }, async ({ repositoryId, top, filterContains, project }) => {
671
704
  try {
672
705
  const connection = await connectionProvider();
673
706
  const gitApi = await connection.getGitApi();
674
- const branches = await gitApi.getRefs(repositoryId, undefined, "heads/", undefined, undefined, undefined, undefined, undefined, filterContains);
707
+ const branches = await gitApi.getRefs(repositoryId, project, "heads/", undefined, undefined, undefined, undefined, undefined, filterContains);
675
708
  const filteredBranches = branchesFilterOutIrrelevantProperties(branches, top);
676
709
  return {
677
710
  content: [{ type: "text", text: JSON.stringify(filteredBranches, null, 2) }],
@@ -686,14 +719,17 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
686
719
  }
687
720
  });
688
721
  server.tool(REPO_TOOLS.list_my_branches_by_repo, "Retrieve a list of my branches for a given repository Id.", {
689
- repositoryId: z.string().describe("The ID of the repository where the branches are located."),
690
- top: z.number().default(100).describe("The maximum number of branches to return."),
722
+ repositoryId: z
723
+ .string()
724
+ .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."),
725
+ top: z.coerce.number().default(100).describe("The maximum number of branches to return."),
691
726
  filterContains: z.string().optional().describe("Filter to find branches that contain this string in their name."),
692
- }, async ({ repositoryId, top, filterContains }) => {
727
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
728
+ }, async ({ repositoryId, top, filterContains, project }) => {
693
729
  try {
694
730
  const connection = await connectionProvider();
695
731
  const gitApi = await connection.getGitApi();
696
- const branches = await gitApi.getRefs(repositoryId, undefined, "heads/", undefined, undefined, true, undefined, undefined, filterContains);
732
+ const branches = await gitApi.getRefs(repositoryId, project, "heads/", undefined, undefined, true, undefined, undefined, filterContains);
697
733
  const filteredBranches = branchesFilterOutIrrelevantProperties(branches, top);
698
734
  return {
699
735
  content: [{ type: "text", text: JSON.stringify(filteredBranches, null, 2) }],
@@ -735,13 +771,14 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
735
771
  }
736
772
  });
737
773
  server.tool(REPO_TOOLS.get_branch_by_name, "Get a branch by its name.", {
738
- repositoryId: z.string().describe("The ID of the repository where the branch is located."),
774
+ 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."),
739
775
  branchName: z.string().describe("The name of the branch to retrieve, e.g., 'main' or 'feature-branch'."),
740
- }, async ({ repositoryId, branchName }) => {
776
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
777
+ }, async ({ repositoryId, branchName, project }) => {
741
778
  try {
742
779
  const connection = await connectionProvider();
743
780
  const gitApi = await connection.getGitApi();
744
- const branches = await gitApi.getRefs(repositoryId, undefined, "heads/", false, false, undefined, false, undefined, branchName);
781
+ const branches = await gitApi.getRefs(repositoryId, project, "heads/", false, false, undefined, false, undefined, branchName);
745
782
  const branch = branches.find((branch) => branch.name === `refs/heads/${branchName}` || branch.name === branchName);
746
783
  if (!branch) {
747
784
  return {
@@ -770,46 +807,80 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
770
807
  repositoryId: z
771
808
  .string()
772
809
  .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."),
773
- pullRequestId: z.number().describe("The ID of the pull request to retrieve."),
810
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request to retrieve."),
774
811
  project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
775
812
  includeWorkItemRefs: z.boolean().optional().default(false).describe("Whether to reference work items associated with the pull request."),
776
813
  includeLabels: z.boolean().optional().default(false).describe("Whether to include a summary of labels in the response."),
777
- }, async ({ repositoryId, pullRequestId, project, includeWorkItemRefs, includeLabels }) => {
814
+ includeChangedFiles: z.boolean().optional().default(false).describe("Whether to include the list of files changed in the pull request."),
815
+ }, async ({ repositoryId, pullRequestId, project, includeWorkItemRefs, includeLabels, includeChangedFiles }) => {
778
816
  try {
779
817
  const connection = await connectionProvider();
780
818
  const gitApi = await connection.getGitApi();
781
819
  const pullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, project, undefined, undefined, undefined, undefined, includeWorkItemRefs);
820
+ let enhancedResponse = { ...pullRequest };
782
821
  if (includeLabels) {
783
822
  try {
784
823
  const projectId = pullRequest.repository?.project?.id;
785
824
  const projectName = pullRequest.repository?.project?.name;
786
825
  const labels = await gitApi.getPullRequestLabels(repositoryId, pullRequestId, projectName, projectId);
787
826
  const labelNames = labels.map((label) => label.name).filter((name) => name !== undefined);
788
- const enhancedResponse = {
789
- ...pullRequest,
827
+ enhancedResponse = {
828
+ ...enhancedResponse,
790
829
  labelSummary: {
791
830
  labels: labelNames,
792
831
  labelCount: labelNames.length,
793
832
  },
794
833
  };
795
- return {
796
- content: [{ type: "text", text: JSON.stringify(enhancedResponse, null, 2) }],
797
- };
798
834
  }
799
835
  catch (error) {
800
836
  console.warn(`Error fetching PR labels: ${error instanceof Error ? error.message : "Unknown error"}`);
801
- // Fall back to the original response without labels
802
- const enhancedResponse = {
803
- ...pullRequest,
837
+ enhancedResponse = {
838
+ ...enhancedResponse,
804
839
  labelSummary: {},
805
840
  };
806
- return {
807
- content: [{ type: "text", text: JSON.stringify(enhancedResponse, null, 2) }],
841
+ }
842
+ }
843
+ if (includeChangedFiles) {
844
+ try {
845
+ const iterations = await gitApi.getPullRequestIterations(repositoryId, pullRequestId, project);
846
+ if (iterations?.length) {
847
+ const latestIteration = iterations[iterations.length - 1];
848
+ if (latestIteration.id != null) {
849
+ const changes = await gitApi.getPullRequestIterationChanges(repositoryId, pullRequestId, latestIteration.id, project);
850
+ enhancedResponse = {
851
+ ...enhancedResponse,
852
+ changedFilesSummary: {
853
+ changeEntries: changes?.changeEntries ?? [],
854
+ fileCount: changes?.changeEntries?.length ?? 0,
855
+ nextSkip: changes?.nextSkip,
856
+ nextTop: changes?.nextTop,
857
+ },
858
+ };
859
+ }
860
+ else {
861
+ enhancedResponse = {
862
+ ...enhancedResponse,
863
+ changedFilesSummary: { changeEntries: [], fileCount: 0 },
864
+ };
865
+ }
866
+ }
867
+ else {
868
+ enhancedResponse = {
869
+ ...enhancedResponse,
870
+ changedFilesSummary: { changeEntries: [], fileCount: 0 },
871
+ };
872
+ }
873
+ }
874
+ catch (error) {
875
+ console.warn(`Error fetching PR changed files: ${error instanceof Error ? error.message : "Unknown error"}`);
876
+ enhancedResponse = {
877
+ ...enhancedResponse,
878
+ changedFilesSummary: {},
808
879
  };
809
880
  }
810
881
  }
811
882
  return {
812
- content: [{ type: "text", text: JSON.stringify(pullRequest, null, 2) }],
883
+ content: [{ type: "text", text: JSON.stringify(enhancedResponse, null, 2) }],
813
884
  };
814
885
  }
815
886
  catch (error) {
@@ -820,12 +891,330 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
820
891
  };
821
892
  }
822
893
  });
823
- server.tool(REPO_TOOLS.reply_to_comment, "Replies to a specific comment on a pull request.", {
894
+ server.tool(REPO_TOOLS.get_pull_request_changes, "Get the file changes (diff) for a pull request iteration with actual code diff content. Returns the code changes including line-by-line diffs made in the pull request.", {
824
895
  repositoryId: z.string().describe("The ID of the repository where the pull request is located."),
825
- pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."),
826
- threadId: z.number().describe("The ID of the thread to which the comment will be added."),
827
- content: z.string().describe("The content of the comment to be added."),
896
+ pullRequestId: z.number().describe("The ID of the pull request to retrieve changes for."),
897
+ iterationId: z.number().optional().describe("The iteration ID to get changes for. If not specified, gets changes for the latest iteration."),
828
898
  project: z.string().optional().describe("Project ID or project name (optional)"),
899
+ top: z.number().optional().describe("Maximum number of files to include diffs for. Default is 100."),
900
+ skip: z.number().optional().describe("Number of changes to skip for pagination."),
901
+ compareTo: z.number().optional().describe("Iteration ID to compare against. If specified, returns changes between two iterations."),
902
+ includeDiffs: z.boolean().optional().describe("Whether to include actual line-by-line diff content. Default is true. Set to false to get only file metadata."),
903
+ includeLineContent: z
904
+ .boolean()
905
+ .optional()
906
+ .describe("Whether to include the actual line content from the changed files. Default is true. When true, fetches file content and includes the actual code lines that were added/removed/modified."),
907
+ }, async ({ repositoryId, pullRequestId, iterationId, project, top, skip, compareTo, includeDiffs = true, includeLineContent = true }) => {
908
+ try {
909
+ const connection = await connectionProvider();
910
+ const gitApi = await connection.getGitApi();
911
+ // If repositoryId is a name (not a GUID), we need a project to resolve it.
912
+ // GUID pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
913
+ const isGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(repositoryId);
914
+ if (!isGuid && !project) {
915
+ return {
916
+ content: [
917
+ {
918
+ type: "text",
919
+ text: "Error: When using a repository name instead of a GUID for repositoryId, the 'project' parameter is required. Please either provide the project name/ID, or use repo_get_repo_by_name_or_id to resolve the repository GUID first.",
920
+ },
921
+ ],
922
+ isError: true,
923
+ };
924
+ }
925
+ // If no iteration ID provided, get the latest iteration
926
+ let targetIterationId = iterationId;
927
+ let targetIteration;
928
+ if (targetIterationId == null) {
929
+ const iterations = await gitApi.getPullRequestIterations(repositoryId, pullRequestId, project);
930
+ if (!iterations || iterations.length === 0) {
931
+ return {
932
+ content: [{ type: "text", text: "No iterations found for this pull request." }],
933
+ isError: true,
934
+ };
935
+ }
936
+ // Get the latest iteration
937
+ targetIteration = iterations[iterations.length - 1];
938
+ targetIterationId = targetIteration.id;
939
+ }
940
+ else {
941
+ // Get the specific iteration
942
+ targetIteration = await gitApi.getPullRequestIteration(repositoryId, pullRequestId, targetIterationId, project);
943
+ }
944
+ // Get the file change metadata
945
+ const changes = await gitApi.getPullRequestIterationChanges(repositoryId, pullRequestId, targetIterationId ?? 1, project, top, skip, compareTo);
946
+ // If includeDiffs is false, just return the metadata
947
+ if (!includeDiffs) {
948
+ return {
949
+ content: [{ type: "text", text: JSON.stringify(changes, null, 2) }],
950
+ };
951
+ }
952
+ // Get actual diff content using getFileDiffs
953
+ if (changes.changeEntries && changes.changeEntries.length > 0 && targetIteration) {
954
+ // Determine base and target commits
955
+ const baseCommitId = compareTo
956
+ ? (await gitApi.getPullRequestIteration(repositoryId, pullRequestId, compareTo, project)).sourceRefCommit?.commitId
957
+ : targetIteration.commonRefCommit?.commitId;
958
+ const targetCommitId = targetIteration.sourceRefCommit?.commitId;
959
+ if (baseCommitId && targetCommitId) {
960
+ // Build FileDiffsCriteria with paths from changeEntries
961
+ // Exclude added and deleted files as they don't have both versions to diff
962
+ // changeType is a flags enum so use bitwise AND to check
963
+ const fileDiffParams = changes.changeEntries
964
+ .filter((entry) => {
965
+ const ct = entry.changeType ?? 0;
966
+ return entry.item?.path && !(ct & VersionControlChangeType.Add) && !(ct & VersionControlChangeType.Delete);
967
+ })
968
+ .map((entry) => {
969
+ // Remove leading slash if present - Azure DevOps API expects relative paths
970
+ const itemPath = entry.item?.path ?? "";
971
+ const path = itemPath.startsWith("/") ? itemPath.substring(1) : itemPath;
972
+ // For renamed/moved files, use the original path from the change entry
973
+ const origPath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : path;
974
+ return {
975
+ path: path,
976
+ originalPath: origPath,
977
+ };
978
+ });
979
+ if (fileDiffParams.length > 0) {
980
+ try {
981
+ // Azure DevOps getFileDiffs API accepts max 10 files per request
982
+ const FILE_DIFF_BATCH_SIZE = 10;
983
+ let fileDiffs = [];
984
+ for (let i = 0; i < fileDiffParams.length; i += FILE_DIFF_BATCH_SIZE) {
985
+ const batch = fileDiffParams.slice(i, i + FILE_DIFF_BATCH_SIZE);
986
+ const batchDiffs = await gitApi.getFileDiffs({
987
+ baseVersionCommit: baseCommitId,
988
+ targetVersionCommit: targetCommitId,
989
+ fileDiffParams: batch,
990
+ }, project || "", repositoryId);
991
+ fileDiffs = fileDiffs.concat(batchDiffs);
992
+ }
993
+ // Merge diff content with change metadata
994
+ const enrichedChanges = {
995
+ ...changes,
996
+ changeEntries: changes.changeEntries.map((entry) => {
997
+ // Normalize path for comparison (remove leading slash)
998
+ const entryPath = entry.item?.path?.startsWith("/") ? entry.item.path.substring(1) : entry.item?.path;
999
+ const matchingDiff = fileDiffs.find((diff) => diff.path === entryPath);
1000
+ return {
1001
+ ...entry,
1002
+ diff: matchingDiff || null,
1003
+ };
1004
+ }),
1005
+ };
1006
+ // If includeLineContent is true, fetch actual file content with concurrency limit
1007
+ if (includeLineContent && enrichedChanges.changeEntries) {
1008
+ const CONCURRENCY_LIMIT = 10;
1009
+ const entriesWithContent = [...enrichedChanges.changeEntries];
1010
+ for (let i = 0; i < entriesWithContent.length; i += CONCURRENCY_LIMIT) {
1011
+ const batch = entriesWithContent.slice(i, i + CONCURRENCY_LIMIT);
1012
+ const batchResults = await Promise.all(batch.map(async (entry) => {
1013
+ const ct = entry.changeType ?? 0;
1014
+ const isAdd = !!(ct & VersionControlChangeType.Add);
1015
+ const isDelete = !!(ct & VersionControlChangeType.Delete);
1016
+ const entryPath = entry.item?.path?.startsWith("/") ? entry.item.path.substring(1) : entry.item?.path;
1017
+ if (!entryPath) {
1018
+ return entry;
1019
+ }
1020
+ // Handle added files: fetch full content at target commit and create synthetic diff
1021
+ if (isAdd && !entry.diff) {
1022
+ try {
1023
+ const targetStream = await gitApi
1024
+ .getItemText(repositoryId, entryPath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit })
1025
+ .catch(() => null);
1026
+ if (targetStream) {
1027
+ const targetText = await streamToString(targetStream);
1028
+ const targetLines = targetText.split(/\r?\n/);
1029
+ return {
1030
+ ...entry,
1031
+ diff: {
1032
+ path: entryPath,
1033
+ originalPath: entryPath,
1034
+ lineDiffBlocks: [
1035
+ {
1036
+ changeType: 1, // Add
1037
+ originalLineNumberStart: 0,
1038
+ originalLinesCount: 0,
1039
+ modifiedLineNumberStart: 1,
1040
+ modifiedLinesCount: targetLines.length,
1041
+ modifiedLines: targetLines,
1042
+ },
1043
+ ],
1044
+ },
1045
+ };
1046
+ }
1047
+ }
1048
+ catch (addError) {
1049
+ return {
1050
+ ...entry,
1051
+ _contentFetchError: `Failed to fetch added file content: ${addError instanceof Error ? addError.message : "Unknown error"}`,
1052
+ };
1053
+ }
1054
+ return entry;
1055
+ }
1056
+ // Handle deleted files: fetch full content at base commit and create synthetic diff
1057
+ if (isDelete && !entry.diff) {
1058
+ try {
1059
+ const basePath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : entryPath;
1060
+ const baseStream = await gitApi
1061
+ .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit })
1062
+ .catch(() => null);
1063
+ if (baseStream) {
1064
+ const baseText = await streamToString(baseStream);
1065
+ const baseLines = baseText.split(/\r?\n/);
1066
+ return {
1067
+ ...entry,
1068
+ diff: {
1069
+ path: entryPath,
1070
+ originalPath: basePath,
1071
+ lineDiffBlocks: [
1072
+ {
1073
+ changeType: 2, // Delete
1074
+ originalLineNumberStart: 1,
1075
+ originalLinesCount: baseLines.length,
1076
+ modifiedLineNumberStart: 0,
1077
+ modifiedLinesCount: 0,
1078
+ originalLines: baseLines,
1079
+ },
1080
+ ],
1081
+ },
1082
+ };
1083
+ }
1084
+ }
1085
+ catch (delError) {
1086
+ return {
1087
+ ...entry,
1088
+ _contentFetchError: `Failed to fetch deleted file content: ${delError instanceof Error ? delError.message : "Unknown error"}`,
1089
+ };
1090
+ }
1091
+ return entry;
1092
+ }
1093
+ // For modified/renamed files, skip if no diff blocks
1094
+ if (!entry.diff?.lineDiffBlocks || entry.diff.lineDiffBlocks.length === 0) {
1095
+ return entry;
1096
+ }
1097
+ // For renamed/moved files, the base version is at the original path
1098
+ const basePath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : entryPath;
1099
+ try {
1100
+ // Fetch file content at both commits
1101
+ const [baseContent, targetContent] = await Promise.all([
1102
+ // Base version (original) - use basePath for renamed files
1103
+ gitApi
1104
+ .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit })
1105
+ .catch(() => null),
1106
+ // Target version (modified)
1107
+ gitApi
1108
+ .getItemText(repositoryId, entryPath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit })
1109
+ .catch(() => null),
1110
+ ]);
1111
+ // Convert streams to text
1112
+ const baseText = baseContent ? await streamToString(baseContent) : "";
1113
+ const targetText = targetContent ? await streamToString(targetContent) : "";
1114
+ // Check if response is an Azure DevOps error (returned as JSON in the stream)
1115
+ const checkForApiError = (text, label) => {
1116
+ if (text.startsWith("{")) {
1117
+ try {
1118
+ const parsed = JSON.parse(text);
1119
+ if (parsed.$id && parsed.innerException !== undefined) {
1120
+ throw new Error(`Failed to fetch ${label} file content: ${parsed.message || text}`);
1121
+ }
1122
+ }
1123
+ catch (e) {
1124
+ if (e instanceof Error && e.message.startsWith("Failed to fetch"))
1125
+ throw e;
1126
+ // Not valid JSON or not an error response — treat as legitimate content
1127
+ }
1128
+ }
1129
+ };
1130
+ checkForApiError(baseText, "base");
1131
+ checkForApiError(targetText, "target");
1132
+ // Split into lines
1133
+ const baseLines = baseText.split(/\r?\n/);
1134
+ const targetLines = targetText.split(/\r?\n/);
1135
+ // Enrich each lineDiffBlock with actual line content
1136
+ const enrichedDiff = {
1137
+ ...entry.diff,
1138
+ lineDiffBlocks: entry.diff.lineDiffBlocks?.map((block) => {
1139
+ const enrichedBlock = { ...block };
1140
+ // Add original (base) lines if they exist
1141
+ if (block.originalLineNumberStart && block.originalLinesCount) {
1142
+ const startIdx = block.originalLineNumberStart - 1;
1143
+ const endIdx = startIdx + block.originalLinesCount;
1144
+ enrichedBlock.originalLines = baseLines.slice(startIdx, endIdx);
1145
+ }
1146
+ // Add modified (target) lines if they exist
1147
+ if (block.modifiedLineNumberStart && block.modifiedLinesCount) {
1148
+ const startIdx = block.modifiedLineNumberStart - 1;
1149
+ const endIdx = startIdx + block.modifiedLinesCount;
1150
+ enrichedBlock.modifiedLines = targetLines.slice(startIdx, endIdx);
1151
+ }
1152
+ return enrichedBlock;
1153
+ }),
1154
+ };
1155
+ return {
1156
+ ...entry,
1157
+ diff: enrichedDiff,
1158
+ };
1159
+ }
1160
+ catch (contentError) {
1161
+ // If content fetch fails, return entry with error
1162
+ return {
1163
+ ...entry,
1164
+ _contentFetchError: `Failed to fetch line content: ${contentError instanceof Error ? contentError.message : "Unknown error"}`,
1165
+ };
1166
+ }
1167
+ }));
1168
+ // Write batch results back into the array
1169
+ for (let j = 0; j < batchResults.length; j++) {
1170
+ entriesWithContent[i + j] = batchResults[j];
1171
+ }
1172
+ }
1173
+ enrichedChanges.changeEntries = entriesWithContent;
1174
+ }
1175
+ return {
1176
+ content: [{ type: "text", text: JSON.stringify(enrichedChanges, null, 2) }],
1177
+ };
1178
+ }
1179
+ catch (diffError) {
1180
+ // If diff fetching fails, return metadata with error info
1181
+ return {
1182
+ content: [
1183
+ {
1184
+ type: "text",
1185
+ text: JSON.stringify({
1186
+ ...changes,
1187
+ _diffError: `Failed to fetch diff content: ${diffError instanceof Error ? diffError.message : "Unknown error"}`,
1188
+ _note: "Returned metadata only",
1189
+ }, null, 2),
1190
+ },
1191
+ ],
1192
+ };
1193
+ }
1194
+ }
1195
+ }
1196
+ }
1197
+ // Fallback: return metadata if we couldn't get diffs
1198
+ return {
1199
+ content: [{ type: "text", text: JSON.stringify(changes, null, 2) }],
1200
+ };
1201
+ }
1202
+ catch (error) {
1203
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
1204
+ return {
1205
+ content: [{ type: "text", text: `Error getting pull request changes: ${errorMessage}` }],
1206
+ isError: true,
1207
+ };
1208
+ }
1209
+ });
1210
+ server.tool(REPO_TOOLS.reply_to_comment, "Replies to a specific comment on a pull request.", {
1211
+ repositoryId: z
1212
+ .string()
1213
+ .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."),
1214
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request where the comment thread exists."),
1215
+ threadId: z.coerce.number().min(1).describe("The ID of the thread to which the comment will be added."),
1216
+ content: z.string().describe("The content of the comment to be added."),
1217
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
829
1218
  fullResponse: z.boolean().optional().default(false).describe("Return full comment JSON response instead of a simple confirmation message."),
830
1219
  }, async ({ repositoryId, pullRequestId, threadId, content, project, fullResponse }) => {
831
1220
  try {
@@ -857,17 +1246,23 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
857
1246
  }
858
1247
  });
859
1248
  server.tool(REPO_TOOLS.create_pull_request_thread, "Creates a new comment thread on a pull request.", {
860
- repositoryId: z.string().describe("The ID of the repository where the pull request is located."),
861
- pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."),
1249
+ repositoryId: z
1250
+ .string()
1251
+ .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."),
1252
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request where the comment thread exists."),
862
1253
  content: z.string().describe("The content of the comment to be added."),
863
- project: z.string().optional().describe("Project ID or project name (optional)"),
1254
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
864
1255
  filePath: z.string().optional().describe("The path of the file where the comment thread will be created. (optional)"),
865
1256
  status: z
866
1257
  .enum(getEnumKeys(CommentThreadStatus))
867
1258
  .optional()
868
1259
  .default(CommentThreadStatus[CommentThreadStatus.Active])
869
1260
  .describe("The status of the comment thread. Defaults to 'Active'."),
870
- 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)"),
1261
+ rightFileStartLine: z.coerce
1262
+ .number()
1263
+ .min(1)
1264
+ .optional()
1265
+ .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)"),
871
1266
  rightFileStartOffset: z
872
1267
  .number()
873
1268
  .optional()
@@ -971,10 +1366,12 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
971
1366
  }
972
1367
  });
973
1368
  server.tool(REPO_TOOLS.update_pull_request_thread, "Updates an existing comment thread on a pull request.", {
974
- repositoryId: z.string().describe("The ID of the repository where the pull request is located."),
975
- pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."),
976
- threadId: z.number().describe("The ID of the thread to update."),
977
- project: z.string().optional().describe("Project ID or project name (optional)"),
1369
+ repositoryId: z
1370
+ .string()
1371
+ .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."),
1372
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request where the comment thread exists."),
1373
+ threadId: z.coerce.number().min(1).describe("The ID of the thread to update."),
1374
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
978
1375
  status: z
979
1376
  .enum(getEnumKeys(CommentThreadStatus))
980
1377
  .optional()
@@ -1026,8 +1423,8 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1026
1423
  .optional()
1027
1424
  .default(GitVersionType[GitVersionType.Branch])
1028
1425
  .describe("The meaning of the version parameter, e.g., branch, tag or commit"),
1029
- skip: z.number().optional().default(0).describe("Number of commits to skip"),
1030
- top: z.number().optional().default(10).describe("Maximum number of commits to return"),
1426
+ skip: z.coerce.number().optional().default(0).describe("Number of commits to skip"),
1427
+ top: z.coerce.number().optional().default(10).describe("Maximum number of commits to return"),
1031
1428
  includeLinks: z.boolean().optional().default(false).describe("Include commit links"),
1032
1429
  includeWorkItems: z.boolean().optional().default(false).describe("Include associated work items"),
1033
1430
  // Enhanced search parameters
@@ -1182,10 +1579,11 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1182
1579
  }
1183
1580
  });
1184
1581
  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.", {
1185
- repositoryId: z.string().describe("The ID of the repository."),
1186
- pullRequestId: z.number().describe("The ID of the pull request."),
1582
+ 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."),
1583
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request."),
1187
1584
  vote: z.enum(["Approved", "ApprovedWithSuggestions", "NoVote", "WaitingForAuthor", "Rejected"]).describe("The vote to cast: Approved(10), Suggestions(5), None(0), Waiting(-5), Rejected(-10)."),
1188
- }, async ({ repositoryId, pullRequestId, vote }) => {
1585
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
1586
+ }, async ({ repositoryId, pullRequestId, vote, project }) => {
1189
1587
  const connection = await connectionProvider();
1190
1588
  const gitApi = await connection.getGitApi();
1191
1589
  const userDetails = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
@@ -1200,7 +1598,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1200
1598
  WaitingForAuthor: -5,
1201
1599
  Rejected: -10,
1202
1600
  };
1203
- await gitApi.createPullRequestReviewer({ vote: voteMap[vote], id: userId }, repositoryId, pullRequestId, userId);
1601
+ await gitApi.createPullRequestReviewer({ vote: voteMap[vote], id: userId }, repositoryId, pullRequestId, userId, project);
1204
1602
  return {
1205
1603
  content: [
1206
1604
  {
@@ -1217,7 +1615,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1217
1615
  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."),
1218
1616
  versionType: z.enum(["Branch", "Commit", "Tag"]).optional().default("Branch").describe("The type of version identifier: 'Branch', 'Commit', or 'Tag'. Defaults to 'Branch'."),
1219
1617
  recursive: z.boolean().optional().default(false).describe("Whether to list items recursively. Defaults to false."),
1220
- recursionDepth: z.number().optional().default(1).describe("Maximum depth for recursive listing (1-10). Only applies when recursive is true. Defaults to 1."),
1618
+ recursionDepth: z.coerce.number().min(1).optional().default(1).describe("Maximum depth for recursive listing (1-10). Only applies when recursive is true. Defaults to 1."),
1221
1619
  }, async ({ repositoryId, path, project, version, versionType, recursive, recursionDepth }) => {
1222
1620
  try {
1223
1621
  const connection = await connectionProvider();
@@ -1276,5 +1674,58 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1276
1674
  };
1277
1675
  }
1278
1676
  });
1677
+ // ── Get file content at a specific version (branch, tag, or commit) ──
1678
+ const fileVersionTypeStrings = getEnumKeys(GitVersionType);
1679
+ server.tool(REPO_TOOLS.get_file_content, "Get the content of a file from a Git repository at a specific version (branch, tag, or commit SHA). " +
1680
+ "Useful for reading source files from PR branches, specific commits, or tags without having them checked out locally.", {
1681
+ repositoryId: z.string().describe("The ID (GUID) or name of the repository."),
1682
+ path: z.string().describe("The full path to the file in the repository, e.g., '/src/main.ts' or 'src/main.ts'."),
1683
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name."),
1684
+ version: z
1685
+ .string()
1686
+ .optional()
1687
+ .describe("Version string: branch name (e.g. 'main'), tag name, or commit SHA. " + "Defaults to the repository's default branch if not specified."),
1688
+ versionType: z
1689
+ .enum(fileVersionTypeStrings)
1690
+ .optional()
1691
+ .default("Commit")
1692
+ .describe("How to interpret the 'version' parameter. Defaults to 'Commit'."),
1693
+ }, async ({ repositoryId, path, project, version, versionType }) => {
1694
+ try {
1695
+ const connection = await connectionProvider();
1696
+ const gitApi = await connection.getGitApi();
1697
+ // Build the version descriptor if a version was specified
1698
+ const versionDescriptor = version
1699
+ ? {
1700
+ version: version,
1701
+ versionType: GitVersionType[versionType],
1702
+ }
1703
+ : undefined;
1704
+ // getItemText returns a ReadableStream of the file content as text
1705
+ const stream = await gitApi.getItemText(repositoryId, path, project, undefined, // scopePath
1706
+ undefined, // recursionLevel
1707
+ undefined, // includeContentMetadata
1708
+ undefined, // latestProcessedChange
1709
+ false, // download
1710
+ versionDescriptor, true // includeContent
1711
+ );
1712
+ const content = await streamToString(stream);
1713
+ return {
1714
+ content: [{ type: "text", text: content }],
1715
+ };
1716
+ }
1717
+ catch (error) {
1718
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
1719
+ return {
1720
+ content: [
1721
+ {
1722
+ type: "text",
1723
+ text: `Error getting file content for '${path}': ${errorMessage}`,
1724
+ },
1725
+ ],
1726
+ isError: true,
1727
+ };
1728
+ }
1729
+ });
1279
1730
  }
1280
1731
  export { REPO_TOOLS, configureRepoTools };