@azure-devops/mcp 2.10.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
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, ItemContentType, 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
6
  import { extractAdoStreamError, getEnumKeys, streamToString, apiVersion } from "../utils.js";
@@ -9,9 +9,11 @@ import { createExternalContentResponse } from "../shared/content-safety.js";
9
9
  const REPO_TOOLS = {
10
10
  repo_repository: "repo_repository",
11
11
  repo_pull_request: "repo_pull_request",
12
+ repo_pull_request_org: "repo_pull_request_org",
12
13
  repo_pull_request_thread: "repo_pull_request_thread",
13
14
  repo_branch: "repo_branch",
14
15
  repo_file: "repo_file",
16
+ repo_file_write: "repo_file_write",
15
17
  repo_search_commits: "repo_search_commits",
16
18
  repo_pull_request_write: "repo_pull_request_write",
17
19
  repo_pull_request_thread_write: "repo_pull_request_thread_write",
@@ -36,10 +38,8 @@ function trimPullRequestThread(thread) {
36
38
  pullRequestThreadContext: thread.pullRequestThreadContext,
37
39
  };
38
40
  }
39
- function trimComments(comments) {
40
- return comments
41
- ?.filter((comment) => !comment.isDeleted)
42
- ?.map((comment) => ({
41
+ function trimComment(comment) {
42
+ return {
43
43
  id: comment.id,
44
44
  author: {
45
45
  displayName: comment.author?.displayName,
@@ -49,7 +49,10 @@ function trimComments(comments) {
49
49
  publishedDate: comment.publishedDate,
50
50
  lastUpdatedDate: comment.lastUpdatedDate,
51
51
  lastContentUpdatedDate: comment.lastContentUpdatedDate,
52
- }));
52
+ };
53
+ }
54
+ function trimComments(comments) {
55
+ return comments?.filter((comment) => !comment.isDeleted)?.map(trimComment);
53
56
  }
54
57
  function pullRequestStatusStringToInt(status) {
55
58
  switch (status) {
@@ -96,6 +99,36 @@ function trimPullRequest(pr, includeDescription = false) {
96
99
  project: pr.repository?.project?.name,
97
100
  };
98
101
  }
102
+ function trimOrganizationPullRequest(pr) {
103
+ return {
104
+ ...trimPullRequest(pr),
105
+ projectId: pr.repository?.project?.id,
106
+ repositoryId: pr.repository?.id,
107
+ createdBy: {
108
+ id: pr.createdBy?.id,
109
+ displayName: pr.createdBy?.displayName,
110
+ uniqueName: pr.createdBy?.uniqueName,
111
+ },
112
+ reviewers: pr.reviewers?.map((reviewer) => ({
113
+ id: reviewer.id,
114
+ displayName: reviewer.displayName,
115
+ uniqueName: reviewer.uniqueName,
116
+ vote: reviewer.vote,
117
+ isRequired: reviewer.isRequired,
118
+ })),
119
+ webUrl: pr.remoteUrl,
120
+ };
121
+ }
122
+ async function getOrganizationPullRequests(connection, identityFilter, identityId, status, skip, top) {
123
+ const url = new URL(`${connection.serverUrl.replace(/\/$/, "")}/_apis/git/pullrequests`);
124
+ url.searchParams.set("api-version", "7.1");
125
+ url.searchParams.set("$skip", String(skip));
126
+ url.searchParams.set("$top", String(top));
127
+ url.searchParams.set(`searchCriteria.${identityFilter}`, identityId);
128
+ url.searchParams.set("searchCriteria.status", String(status));
129
+ const response = await connection.rest.get(url.toString(), { deserializeDates: true });
130
+ return response.result?.value ?? [];
131
+ }
99
132
  function buildVersionDescriptor(version, versionType) {
100
133
  if (!version)
101
134
  return undefined;
@@ -159,18 +192,22 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
159
192
  }
160
193
  });
161
194
  // --- repo_pull_request -----------------------------------------------------
162
- server.tool(REPO_TOOLS.repo_pull_request, "Retrieve pull request data. Use the action parameter to specify the operation.", {
195
+ server.tool(REPO_TOOLS.repo_pull_request, "Retrieve pull request data for a specific repository or project, or retrieve one pull request by ID. For the authenticated user's active pull requests across the entire organization, use repo_pull_request_org.", {
163
196
  action: z
164
197
  .enum(["get", "list", "list_by_commits"])
165
198
  .describe("The action to perform. Options: get (get a pull request by ID), list (list pull requests in a repository or project), list_by_commits (find pull requests that contain specific commit IDs)."),
166
199
  repositoryId: z.string().optional().describe("The ID or name of the repository. Required for get. Optional for list. When using a name instead of a GUID, project must also be provided."),
167
200
  pullRequestId: z.coerce.number().min(1).optional().describe("The ID of the pull request. Required for get."),
168
- project: z.string().optional().describe("Project ID or project name. Required for list_by_commits. Optional for get and list."),
201
+ project: z.string().optional().describe("Project ID or name. Required for list_by_commits and when repositoryId is a name. Optional when repositoryId is a GUID."),
169
202
  includeWorkItemRefs: z.boolean().optional().default(false).describe("Whether to include work item references. Used for get."),
170
203
  includeLabels: z.boolean().optional().default(false).describe("Whether to include labels. Used for get."),
171
- includeChangedFiles: z.boolean().optional().default(false).describe("Whether to include the list of changed files. Used for get."),
172
- top: z.coerce.number().default(100).describe("The maximum number of pull requests to return. Used for list. Defaults to 100."),
173
- skip: z.coerce.number().default(0).describe("The number of pull requests to skip. Used for list. Defaults to 0."),
204
+ includeChangedFiles: z
205
+ .boolean()
206
+ .optional()
207
+ .default(false)
208
+ .describe("Whether to include the list of changed files. When changedFilesSummary returns a nextSkip greater than 0, pass nextSkip and nextTop back as skip and top for the next page. Used for get."),
209
+ top: z.coerce.number().default(100).describe("The maximum number of pull requests to return (list), or of changed files to return (get with includeChangedFiles). Defaults to 100."),
210
+ skip: z.coerce.number().default(0).describe("The number of pull requests to skip (list), or of changed files to skip (get with includeChangedFiles). Defaults to 0."),
174
211
  created_by_me: z.boolean().default(false).describe("Filter pull requests created by the current user. Used for list."),
175
212
  created_by_user: z.string().optional().describe("Filter pull requests created by a specific user email. Used for list."),
176
213
  i_am_reviewer: z.boolean().default(false).describe("Filter pull requests where the current user is a reviewer. Used for list."),
@@ -190,13 +227,19 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
190
227
  .describe("Type of commit query. Used for list_by_commits."),
191
228
  }, async ({ action, repositoryId, pullRequestId, project, includeWorkItemRefs, includeLabels, includeChangedFiles, top, skip, created_by_me, created_by_user, i_am_reviewer, user_is_reviewer, status, sourceRefName, targetRefName, repository, commits, queryType, }) => {
192
229
  try {
193
- const connection = await connectionProvider();
194
- const gitApi = await connection.getGitApi();
195
230
  if (action === "get") {
196
231
  if (!repositoryId)
197
232
  return { content: [{ type: "text", text: "repositoryId is required for get" }], isError: true };
198
233
  if (!pullRequestId)
199
234
  return { content: [{ type: "text", text: "pullRequestId is required for get" }], isError: true };
235
+ if (!project && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(repositoryId)) {
236
+ return {
237
+ content: [{ type: "text", text: "When repositoryId is a repository name, project is required. Provide the project name or ID, or use the repository GUID." }],
238
+ isError: true,
239
+ };
240
+ }
241
+ const connection = await connectionProvider();
242
+ const gitApi = await connection.getGitApi();
200
243
  const pullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, project, undefined, undefined, undefined, undefined, includeWorkItemRefs);
201
244
  let enhancedResponse = { ...pullRequest };
202
245
  if (includeLabels) {
@@ -218,7 +261,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
218
261
  if (iterations?.length) {
219
262
  const latestIteration = iterations[iterations.length - 1];
220
263
  if (latestIteration.id != null) {
221
- const changes = await gitApi.getPullRequestIterationChanges(repositoryId, pullRequestId, latestIteration.id, project);
264
+ const changes = await gitApi.getPullRequestIterationChanges(repositoryId, pullRequestId, latestIteration.id, project, top, skip);
222
265
  enhancedResponse = {
223
266
  ...enhancedResponse,
224
267
  changedFilesSummary: {
@@ -246,6 +289,8 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
246
289
  }
247
290
  return createExternalContentResponse(enhancedResponse, "pull request");
248
291
  }
292
+ const connection = await connectionProvider();
293
+ const gitApi = await connection.getGitApi();
249
294
  if (action === "list") {
250
295
  if (!repositoryId && !project) {
251
296
  return { content: [{ type: "text", text: "Either repositoryId or project must be provided." }], isError: true };
@@ -319,6 +364,41 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
319
364
  return { content: [{ type: "text", text: `Error with pull request operation: ${errorMessage}` }], isError: true };
320
365
  }
321
366
  });
367
+ // --- repo_pull_request_org -------------------------------------------------
368
+ server.tool(REPO_TOOLS.repo_pull_request_org, "List active pull requests across all projects and repositories in the organization for the authenticated user. Use this tool for organization-wide requests such as 'my pull requests' or 'pull requests awaiting my review'. For a specific project, repository, or pull request ID, use repo_pull_request.", {
369
+ is_reviewer: z
370
+ .boolean()
371
+ .default(false)
372
+ .describe("Set to true for active pull requests where the authenticated user is a reviewer. Set to false for active pull requests created by the authenticated user."),
373
+ reviewStatus: z
374
+ .enum(["all", "approved", "pending"])
375
+ .default("all")
376
+ .describe("Filter by the authenticated user's reviewer vote when is_reviewer is true: all includes every vote, approved includes Approved and Approved with suggestions, and pending includes only No vote."),
377
+ top: z.coerce.number().default(100).describe("The maximum number of active pull requests to retrieve before applying the reviewStatus filter. Defaults to 100."),
378
+ skip: z.coerce.number().default(0).describe("The number of active pull requests to skip before applying the reviewStatus filter. Defaults to 0."),
379
+ }, async ({ is_reviewer, reviewStatus, top, skip }) => {
380
+ try {
381
+ const currentUser = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
382
+ const userId = currentUser.authenticatedUser.id;
383
+ const identityFilter = is_reviewer ? "reviewerId" : "creatorId";
384
+ const connection = await connectionProvider();
385
+ const pullRequests = await getOrganizationPullRequests(connection, identityFilter, userId, PullRequestStatus.Active, skip, top);
386
+ const filteredPullRequests = pullRequests.filter((pullRequest) => {
387
+ if (!is_reviewer || reviewStatus === "all")
388
+ return true;
389
+ const reviewer = pullRequest.reviewers?.find((item) => item.id === userId);
390
+ if (reviewStatus === "approved")
391
+ return reviewer?.vote === 10 || reviewer?.vote === 5;
392
+ return reviewer?.vote === 0;
393
+ });
394
+ const trimmedPullRequests = filteredPullRequests.map((pullRequest) => trimOrganizationPullRequest(pullRequest));
395
+ return { content: [{ type: "text", text: JSON.stringify(trimmedPullRequests, null, 2) }] };
396
+ }
397
+ catch (error) {
398
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
399
+ return { content: [{ type: "text", text: `Error with organization pull request operation: ${errorMessage}` }], isError: true };
400
+ }
401
+ });
322
402
  // --- repo_pull_request_thread ----------------------------------------------
323
403
  server.tool(REPO_TOOLS.repo_pull_request_thread, "Retrieve pull request thread and comment data. Use the action parameter to specify the operation.", {
324
404
  action: z.enum(["list", "list_comments"]).describe("The action to perform. Options: list (list comment threads on a pull request), list_comments (list comments in a specific thread)."),
@@ -440,8 +520,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
440
520
  versionType: z
441
521
  .enum(fileVersionTypeStrings)
442
522
  .optional()
443
- .default("Commit")
444
- .describe("How to interpret the version parameter. Used for get_content. Defaults to 'Commit'."),
523
+ .describe("How to interpret the version parameter. Defaults to 'Commit' for get_content and 'Branch' for list_directory."),
445
524
  recursive: z.boolean().optional().default(false).describe("Whether to list items recursively. Used for list_directory. Defaults to false."),
446
525
  recursionDepth: z.coerce.number().min(1).optional().default(1).describe("Maximum depth for recursive listing. Used for list_directory when recursive is true. Defaults to 1."),
447
526
  }, async ({ action, repositoryId, path, project, version, versionType, recursive, recursionDepth }) => {
@@ -451,7 +530,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
451
530
  if (action === "get_content") {
452
531
  if (!path)
453
532
  return { content: [{ type: "text", text: "path is required for get_content" }], isError: true };
454
- const versionDescriptor = version ? { version, versionType: GitVersionType[versionType] } : undefined;
533
+ const versionDescriptor = buildVersionDescriptor(version, versionType ?? "Commit");
455
534
  const stream = await gitApi.getItemText(repositoryId, path, project, undefined, undefined, undefined, undefined, false, versionDescriptor, true);
456
535
  const content = await streamToString(stream);
457
536
  const streamError = extractAdoStreamError(content);
@@ -461,7 +540,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
461
540
  return createExternalContentResponse(content, "repository file");
462
541
  }
463
542
  if (action === "list_directory") {
464
- const versionDescriptor = buildVersionDescriptor(version, versionType === "Commit" ? "Branch" : versionType);
543
+ const versionDescriptor = buildVersionDescriptor(version, versionType);
465
544
  const clampedDepth = Math.min(Math.max(recursionDepth || 1, 1), 10);
466
545
  const recursionType = recursive ? VersionControlRecursionType.Full : VersionControlRecursionType.OneLevel;
467
546
  const items = await gitApi.getItems(repositoryId, project, path, recursionType, true, false, false, false, versionDescriptor);
@@ -502,6 +581,63 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
502
581
  return { content: [{ type: "text", text: `Error with file operation: ${errorMessage}` }], isError: true };
503
582
  }
504
583
  });
584
+ // --- repo_file_write -------------------------------------------------------
585
+ server.tool(REPO_TOOLS.repo_file_write, "Create or update a text file in an existing repository branch and commit the change.", {
586
+ action: z.enum(["create", "update"]).describe("Whether to create a new file or update an existing file."),
587
+ repositoryId: z.string().describe("The ID or name of the repository."),
588
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name."),
589
+ branchName: z.string().min(1).describe("The name of the existing branch to update, without the 'refs/heads/' prefix."),
590
+ path: z.string().min(1).describe("The absolute repository path of the file, for example '/src/example.ts'."),
591
+ content: z.string().describe("The complete UTF-8 text content to write to the file."),
592
+ commitMessage: z.string().min(1).describe("The commit message for the file change."),
593
+ expectedOldObjectId: z
594
+ .string()
595
+ .optional()
596
+ .describe("The expected current commit ID of the branch. When provided, the push fails if the branch has moved. If omitted, the current branch head is retrieved before pushing."),
597
+ }, async ({ action, repositoryId, project, branchName, path, content, commitMessage, expectedOldObjectId }) => {
598
+ try {
599
+ const connection = await connectionProvider();
600
+ const gitApi = await connection.getGitApi();
601
+ const refName = `refs/heads/${branchName}`;
602
+ const refs = await gitApi.getRefs(repositoryId, project, "heads/", false, false, undefined, false, undefined, branchName);
603
+ const branch = refs.find((ref) => ref.name === refName);
604
+ if (!branch?.objectId) {
605
+ return { content: [{ type: "text", text: `Error: Branch '${branchName}' not found in repository ${repositoryId}` }], isError: true };
606
+ }
607
+ if (expectedOldObjectId && expectedOldObjectId !== branch.objectId) {
608
+ return { content: [{ type: "text", text: `Error: Branch '${branchName}' has moved from expected commit ${expectedOldObjectId}` }], isError: true };
609
+ }
610
+ const oldObjectId = branch.objectId;
611
+ const filePath = path.startsWith("/") ? path : `/${path}`;
612
+ const push = await gitApi.createPush({
613
+ refUpdates: [{ name: refName, oldObjectId }],
614
+ commits: [
615
+ {
616
+ comment: commitMessage,
617
+ changes: [
618
+ {
619
+ changeType: action === "create" ? VersionControlChangeType.Add : VersionControlChangeType.Edit,
620
+ item: { path: filePath },
621
+ newContent: { content, contentType: ItemContentType.RawText },
622
+ },
623
+ ],
624
+ },
625
+ ],
626
+ }, repositoryId, project);
627
+ return {
628
+ content: [
629
+ {
630
+ type: "text",
631
+ text: JSON.stringify({ pushId: push.pushId, commitId: push.commits?.[0]?.commitId, branchName, path: filePath }, null, 2),
632
+ },
633
+ ],
634
+ };
635
+ }
636
+ catch (error) {
637
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
638
+ return { content: [{ type: "text", text: `Error writing repository file: ${errorMessage}` }], isError: true };
639
+ }
640
+ });
505
641
  // --- repo_search_commits ---------------------------------------------------
506
642
  server.tool(REPO_TOOLS.repo_search_commits, "Search commits with filtering by text, author, date range, and more.", {
507
643
  searchText: z.string().describe("Keywords to search for in commit messages"),
@@ -566,7 +702,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
566
702
  targetRefName: z.string().optional().describe("The target branch name (e.g., 'refs/heads/main'). Required for create. Optional for update."),
567
703
  title: z.string().optional().describe("The title of the pull request. Required for create. Optional for update."),
568
704
  description: z.string().max(4000).optional().describe("The description of the pull request. Max 4000 characters. Used for create and update."),
569
- isDraft: z.boolean().optional().default(false).describe("Whether the pull request is a draft. Used for create and update."),
705
+ isDraft: z.boolean().optional().describe("Whether the pull request is a draft. Defaults to false for create. When omitted during update, the existing draft state is preserved."),
570
706
  workItems: z.string().optional().describe("Work item IDs to associate, space-separated. Used for create."),
571
707
  forkSourceRepositoryId: z.string().optional().describe("The ID of the fork repository. Used for create."),
572
708
  labels: z.array(z.string()).optional().describe("Array of label names. Used for create and update."),
@@ -602,7 +738,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
602
738
  "Try the repository GUID from repo_repository (list action) instead of the Project/RepoName slash format.";
603
739
  const forkSource = forkSourceRepositoryId ? { repository: { id: forkSourceRepositoryId } } : undefined;
604
740
  const labelDefinitions = labels ? labels.map((label) => ({ name: label })) : undefined;
605
- let pullRequest = await gitApi.createPullRequest({ sourceRefName, targetRefName, title, description, isDraft, workItemRefs, forkSource, labels: labelDefinitions, supportsIterations: true }, repositoryId, project);
741
+ let pullRequest = await gitApi.createPullRequest({ sourceRefName, targetRefName, title, description, isDraft: isDraft ?? false, workItemRefs, forkSource, labels: labelDefinitions, supportsIterations: true }, repositoryId, project);
606
742
  if (!pullRequest) {
607
743
  const prs = await gitApi.getPullRequests(repositoryId, { sourceRefName, targetRefName, status: PullRequestStatus.Active }, project, undefined, 0, 1);
608
744
  if (prs && prs.length > 0) {
@@ -766,14 +902,13 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
766
902
  status: z
767
903
  .enum(getEnumKeys(CommentThreadStatus))
768
904
  .optional()
769
- .default(CommentThreadStatus[CommentThreadStatus.Active])
770
- .describe("The thread status. Used for create (defaults to 'Active') and required for update_status."),
905
+ .describe("The thread status. Used for create (defaults to 'Active'), optionally for reply, and required for update_status."),
771
906
  filePath: z.string().optional().describe("The file path for the comment thread. Used for create."),
772
907
  fullResponse: z.boolean().optional().default(false).describe("Return full JSON response. Used for reply and update."),
773
908
  rightFileStartLine: z.coerce.number().min(1).optional().describe("Start line in the right file. Used for create."),
774
909
  rightFileStartOffset: z.number().optional().describe("Start character offset in the right file. Used for create."),
775
- rightFileEndLine: z.number().optional().describe("End line in the right file. Used for create."),
776
- rightFileEndOffset: z.number().optional().describe("End character offset in the right file. Used for create."),
910
+ rightFileEndLine: z.number().optional().describe("End line in the right file. Used for create. Must be supplied together with rightFileEndOffset."),
911
+ rightFileEndOffset: z.number().optional().describe("End character offset in the right file. Used for create. Must be supplied together with rightFileEndLine."),
777
912
  changeTrackingId: z.coerce.number().int().min(1).optional().describe("The file change tracking ID from the pull request iteration changes. Used for create."),
778
913
  firstComparingIteration: z.coerce.number().int().min(0).optional().describe("The iteration on the left side of the diff. Used for create."),
779
914
  secondComparingIteration: z.coerce.number().int().min(1).optional().describe("The iteration on the right side of the diff. Used for create."),
@@ -837,7 +972,8 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
837
972
  const pullRequestThreadContext = changeTrackingId !== undefined && firstComparingIteration !== undefined && secondComparingIteration !== undefined
838
973
  ? { changeTrackingId, iterationContext: { firstComparingIteration, secondComparingIteration } }
839
974
  : undefined;
840
- const thread = await gitApi.createThread({ comments: [{ content, commentType: 1 }], threadContext, pullRequestThreadContext, status: CommentThreadStatus[status] }, repositoryId, pullRequestId, project);
975
+ const threadStatus = status === undefined ? CommentThreadStatus.Active : CommentThreadStatus[status];
976
+ const thread = await gitApi.createThread({ comments: [{ content, commentType: 1 }], threadContext, pullRequestThreadContext, status: threadStatus }, repositoryId, pullRequestId, project);
841
977
  return { content: [{ type: "text", text: JSON.stringify(trimPullRequestThread(thread), null, 2) }] };
842
978
  }
843
979
  if (action === "reply") {
@@ -849,9 +985,55 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
849
985
  if (!comment) {
850
986
  return { content: [{ type: "text", text: `Error: Failed to add comment to thread ${threadId}. The comment was not created successfully.` }], isError: true };
851
987
  }
988
+ if (status !== undefined) {
989
+ let statusUpdateError;
990
+ let resultingThreadStatus;
991
+ try {
992
+ const thread = await gitApi.updateThread({ status: CommentThreadStatus[status] }, repositoryId, pullRequestId, threadId, project);
993
+ if (!thread) {
994
+ statusUpdateError = `Thread ${threadId} was not updated successfully.`;
995
+ }
996
+ else {
997
+ resultingThreadStatus = thread.status;
998
+ }
999
+ }
1000
+ catch (error) {
1001
+ statusUpdateError = error instanceof Error ? error.message : "Unknown error occurred";
1002
+ }
1003
+ if (statusUpdateError !== undefined) {
1004
+ return {
1005
+ content: [
1006
+ {
1007
+ type: "text",
1008
+ text: JSON.stringify({
1009
+ message: `Comment ${comment.id} was posted successfully, but the thread status update failed. Do not retry the reply.`,
1010
+ comment: trimComment(comment),
1011
+ statusUpdate: {
1012
+ succeeded: false,
1013
+ requestedStatus: status,
1014
+ error: statusUpdateError,
1015
+ },
1016
+ }, null, 2),
1017
+ },
1018
+ ],
1019
+ isError: true,
1020
+ };
1021
+ }
1022
+ return {
1023
+ content: [
1024
+ {
1025
+ type: "text",
1026
+ text: JSON.stringify({
1027
+ comment: trimComment(comment),
1028
+ status: resultingThreadStatus,
1029
+ }, null, 2),
1030
+ },
1031
+ ],
1032
+ };
1033
+ }
852
1034
  if (fullResponse)
853
1035
  return { content: [{ type: "text", text: JSON.stringify(comment, null, 2) }] };
854
- return { content: [{ type: "text", text: `Comment successfully added to thread ${threadId}.` }] };
1036
+ return { content: [{ type: "text", text: JSON.stringify(trimComment(comment), null, 2) }] };
855
1037
  }
856
1038
  if (action === "update") {
857
1039
  if (!threadId)
@@ -63,7 +63,7 @@ function configureSearchTools(server, tokenProvider, connectionProvider, userAge
63
63
  const combinedResults = await fetchCombinedResults(resultJson.results ?? [], gitApi);
64
64
  return createExternalContentResponse(resultText + JSON.stringify(combinedResults), "code search results");
65
65
  });
66
- server.tool(SEARCH_TOOLS.search_wiki, "Search Azure DevOps Wiki for a given search text", {
66
+ server.tool(SEARCH_TOOLS.search_wiki, "Search Azure DevOps Wiki for a given search text. Results include the repository path and a wiki-relative pagePath that can be used with the wiki tool.", {
67
67
  searchText: z.string().describe("Keywords to search for wiki pages"),
68
68
  project: z.array(z.string()).optional().describe("Filter by projects"),
69
69
  wiki: z.array(z.string()).optional().describe("Filter by wiki names"),
@@ -99,8 +99,10 @@ function configureSearchTools(server, tokenProvider, connectionProvider, userAge
99
99
  if (!response.ok) {
100
100
  throw new Error(`Azure DevOps Wiki Search API error: ${response.status} ${response.statusText}`);
101
101
  }
102
- const result = await response.text();
103
- return createExternalContentResponse(result, "wiki search results");
102
+ const result = (await response.json());
103
+ const results = result.results?.map(addWikiPagePath);
104
+ const enrichedResult = results ? { ...result, results } : result;
105
+ return createExternalContentResponse(JSON.stringify(enrichedResult), "wiki search results");
104
106
  });
105
107
  server.tool(SEARCH_TOOLS.search_workitem, "Get Azure DevOps Work Item search results for a given search text", {
106
108
  searchText: z.string().describe("Search text to find in work items"),
@@ -151,6 +153,27 @@ function configureSearchTools(server, tokenProvider, connectionProvider, userAge
151
153
  return createExternalContentResponse(result, "work item search results");
152
154
  });
153
155
  }
156
+ function addWikiPagePath(result) {
157
+ if (!result || result.pagePath || !result.path || !result.wiki?.mappedPath) {
158
+ return result;
159
+ }
160
+ const mappedPath = result.wiki.mappedPath === "/" ? "" : result.wiki.mappedPath.replace(/\/+$/, "");
161
+ if (mappedPath && result.path !== mappedPath && !result.path.startsWith(`${mappedPath}/`)) {
162
+ return result;
163
+ }
164
+ let relativePath;
165
+ try {
166
+ relativePath = decodeURIComponent(result.path.slice(mappedPath.length).replace(/\.md$/i, ""));
167
+ }
168
+ catch (error) {
169
+ if (error instanceof URIError) {
170
+ return result;
171
+ }
172
+ throw error;
173
+ }
174
+ const pagePath = relativePath.startsWith("/") ? relativePath : `/${relativePath}`;
175
+ return { ...result, pagePath };
176
+ }
154
177
  async function fetchCombinedResults(topSearchResults, gitApi) {
155
178
  const combinedResults = [];
156
179
  for (const searchResult of topSearchResults) {
@@ -1,7 +1,27 @@
1
1
  // Copyright (c) Microsoft Corporation.
2
2
  // Licensed under the MIT License.
3
+ import { TestOutcome } from "azure-devops-node-api/interfaces/TestInterfaces.js";
3
4
  import { z } from "zod";
4
5
  import { apiVersion } from "../utils.js";
6
+ const TEST_RESULT_PAGE_SIZE = 1_000;
7
+ const TEST_RESULT_CONCURRENCY = 5;
8
+ const TEST_OUTCOMES = [
9
+ "Unspecified",
10
+ "None",
11
+ "Passed",
12
+ "Failed",
13
+ "Inconclusive",
14
+ "Timeout",
15
+ "Aborted",
16
+ "Blocked",
17
+ "NotExecuted",
18
+ "Warning",
19
+ "Error",
20
+ "NotApplicable",
21
+ "Paused",
22
+ "InProgress",
23
+ "NotImpacted",
24
+ ];
5
25
  const TEST_PLAN_TOOLS = {
6
26
  testplan: "testplan",
7
27
  test_results_from_build_id: "testplan_show_test_results_from_build_id",
@@ -137,33 +157,50 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider, userA
137
157
  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.", {
138
158
  project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
139
159
  buildid: z.coerce.number().min(1).describe("The ID of the build."),
140
- outcomes: z.array(z.string()).optional().describe("Filter results by test outcome, e.g. ['Failed', 'Passed', 'Aborted']."),
160
+ outcomes: z.array(z.enum(TEST_OUTCOMES)).optional().describe("Filter results by test outcome, e.g. ['Failed', 'Passed', 'Aborted']."),
141
161
  }, async ({ project, buildid, outcomes }) => {
142
162
  try {
143
163
  const connection = await connectionProvider();
144
164
  const testResultsApi = await connection.getTestResultsApi();
145
- const outcomeFilter = outcomes?.length ? `Outcome eq ${outcomes.join(",")}` : undefined;
146
- const testResultDetails = await testResultsApi.getTestResultDetailsForBuild(project, buildid, undefined, undefined, outcomeFilter, undefined, true);
165
+ const buildUri = `vstfs:///Build/Build/${buildid}`;
166
+ const testRuns = [];
167
+ for (let skip = 0;; skip += TEST_RESULT_PAGE_SIZE) {
168
+ const page = await testResultsApi.getTestRuns(project, buildUri, undefined, undefined, undefined, undefined, undefined, skip, TEST_RESULT_PAGE_SIZE);
169
+ testRuns.push(...page);
170
+ if (page.length < TEST_RESULT_PAGE_SIZE) {
171
+ break;
172
+ }
173
+ }
174
+ const outcomeFilter = outcomes?.map((outcome) => TestOutcome[outcome]);
175
+ const runsWithIds = testRuns.filter((run) => run.id !== undefined);
147
176
  const allResults = [];
148
- if (testResultDetails.resultsForGroup) {
149
- for (const group of testResultDetails.resultsForGroup) {
150
- if (group.results) {
151
- for (const result of group.results) {
152
- allResults.push(result);
177
+ for (let index = 0; index < runsWithIds.length; index += TEST_RESULT_CONCURRENCY) {
178
+ const batch = runsWithIds.slice(index, index + TEST_RESULT_CONCURRENCY);
179
+ const batchResults = await Promise.all(batch.map(async (run) => {
180
+ const results = [];
181
+ for (let skip = 0;; skip += TEST_RESULT_PAGE_SIZE) {
182
+ const page = await testResultsApi.getTestResults(project, run.id, undefined, skip, TEST_RESULT_PAGE_SIZE, outcomeFilter);
183
+ results.push(...page.map((result) => ({ result, runId: run.id })));
184
+ if (page.length < TEST_RESULT_PAGE_SIZE) {
185
+ break;
153
186
  }
154
187
  }
188
+ return results;
189
+ }));
190
+ for (const results of batchResults) {
191
+ allResults.push(...results);
155
192
  }
156
193
  }
157
- const formattedResults = allResults.map((r) => ({
158
- id: r.id,
159
- testCaseTitle: r.testCaseTitle,
160
- outcome: r.outcome,
161
- errorMessage: r.errorMessage,
162
- stackTrace: r.stackTrace,
163
- automatedTestName: r.automatedTestName,
164
- automatedTestStorage: r.automatedTestStorage,
165
- durationInMs: r.durationInMs,
166
- runId: r.testRun?.id,
194
+ const formattedResults = allResults.map(({ result, runId }) => ({
195
+ id: result.id,
196
+ testCaseTitle: result.testCaseTitle,
197
+ outcome: result.outcome,
198
+ errorMessage: result.errorMessage,
199
+ stackTrace: result.stackTrace,
200
+ automatedTestName: result.automatedTestName,
201
+ automatedTestStorage: result.automatedTestStorage,
202
+ durationInMs: result.durationInMs,
203
+ runId,
167
204
  }));
168
205
  return {
169
206
  content: [{ type: "text", text: JSON.stringify(formattedResults, null, 2) }],
@@ -19,7 +19,7 @@ function configureWikiTools(server, tokenProvider, connectionProvider, userAgent
19
19
  .string()
20
20
  .optional()
21
21
  .describe("The full URL of the wiki page. Used for get_page_content. If provided, wikiIdentifier, project, and path are ignored. Supported patterns: https://dev.azure.com/{org}/{project}/_wiki/wikis/{wikiIdentifier}?pagePath=%2FMy%20Page and https://dev.azure.com/{org}/{project}/_wiki/wikis/{wikiIdentifier}/{pageId}/Page-Title"),
22
- top: z.coerce.number().default(20).describe("The maximum number of pages to return. Used for list_pages. Defaults to 20."),
22
+ top: z.coerce.number().default(20).describe("Maximum number of pages to return for list_pages (up to 100; defaults to 20)."),
23
23
  continuationToken: z.string().optional().describe("Token for pagination to retrieve the next set of pages. Used for list_pages."),
24
24
  pageViewsForDays: z.coerce.number().optional().describe("Number of days to retrieve page views for. Used for list_pages. If not specified, page views are not included."),
25
25
  recursionLevel: z
@@ -178,7 +178,7 @@ function configureWikiTools(server, tokenProvider, connectionProvider, userAgent
178
178
  catch { }
179
179
  }
180
180
  }
181
- if (!pageContent) {
181
+ if (pageContent === undefined) {
182
182
  if (!resolvedPath) {
183
183
  resolvedPath = "/";
184
184
  }
@@ -346,18 +346,18 @@ function parseWikiUrl(url) {
346
346
  if (idx < 1 || segments[idx + 1] !== "wikis") {
347
347
  return { error: "URL does not match expected wiki pattern (missing /_wiki/wikis/ segment)." };
348
348
  }
349
- const project = segments[idx - 1];
350
- const wikiIdentifier = segments[idx + 2];
351
- if (!project || !wikiIdentifier) {
349
+ const encodedProject = segments[idx - 1];
350
+ const encodedWikiIdentifier = segments[idx + 2];
351
+ if (!encodedProject || !encodedWikiIdentifier) {
352
352
  return { error: "Could not extract project or wikiIdentifier from URL." };
353
353
  }
354
+ const project = decodeURIComponent(encodedProject);
355
+ const wikiIdentifier = decodeURIComponent(encodedWikiIdentifier);
354
356
  // Query form with pagePath
355
357
  const pagePathParam = u.searchParams.get("pagePath");
356
358
  if (pagePathParam) {
357
- let decoded = decodeURIComponent(pagePathParam);
358
- if (!decoded.startsWith("/"))
359
- decoded = "/" + decoded;
360
- return { project, wikiIdentifier, pagePath: decoded };
359
+ const pagePath = pagePathParam.startsWith("/") ? pagePathParam : `/${pagePathParam}`;
360
+ return { project, wikiIdentifier, pagePath };
361
361
  }
362
362
  // Path ID form: .../wikis/{wikiIdentifier}/{pageId}/...
363
363
  const afterWiki = segments.slice(idx + 3); // elements after wikiIdentifier