@azure-devops/mcp 2.9.0-nightly.20260831 → 2.9.0-nightly.20260902

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,7 @@
1
1
  // Copyright (c) Microsoft Corporation.
2
2
  // Licensed under the MIT License.
3
3
  import { randomBytes } from "crypto";
4
+ const spotlightedResponse = Symbol("spotlightedResponse");
4
5
  /**
5
6
  * Applies Spotlighting (delimiting mode) to untrusted external content.
6
7
  * See: https://arxiv.org/pdf/2403.14720
@@ -20,5 +21,31 @@ export function spotlightContent(content, source) {
20
21
  export function createExternalContentResponse(content, source) {
21
22
  const serialized = typeof content === "string" ? content : JSON.stringify(content, null, 2);
22
23
  const spotlighted = spotlightContent(serialized, source);
23
- return { content: [{ type: "text", text: spotlighted }] };
24
+ const response = { content: [{ type: "text", text: spotlighted }] };
25
+ response[spotlightedResponse] = true;
26
+ return response;
27
+ }
28
+ /**
29
+ * Applies Spotlighting to every textual content block in a tool response while
30
+ * preserving MCP metadata such as `isError` and `_meta`.
31
+ *
32
+ * The private symbol prevents responses created by createExternalContentResponse
33
+ * from being wrapped twice without trusting attacker-controlled marker text.
34
+ */
35
+ export function wrapExternalToolResponse(response, source) {
36
+ const markedResponse = response;
37
+ if (markedResponse[spotlightedResponse])
38
+ return response;
39
+ const content = response.content.map((block) => {
40
+ if (block.type === "text") {
41
+ return { ...block, text: spotlightContent(block.text, source) };
42
+ }
43
+ if (block.type === "resource" && "text" in block.resource) {
44
+ return { ...block, resource: { ...block.resource, text: spotlightContent(block.resource.text, source) } };
45
+ }
46
+ return block;
47
+ });
48
+ const wrapped = { ...response, content };
49
+ wrapped[spotlightedResponse] = true;
50
+ return wrapped;
24
51
  }
@@ -579,11 +579,12 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
579
579
  mergeCommitMessage: z.string().optional().describe("Commit message for autocomplete. Used for update."),
580
580
  deleteSourceBranch: z.boolean().optional().default(false).describe("Delete source branch on autocomplete. Used for update."),
581
581
  transitionWorkItems: z.boolean().optional().default(true).describe("Transition work items on autocomplete. Used for update."),
582
- bypassReason: z.string().optional().describe("Reason for bypassing branch policies. Used for update."),
582
+ bypassPolicy: z.boolean().optional().default(false).describe("Explicitly bypass branch policies on autocomplete. Used for update and requires bypassReason when true."),
583
+ bypassReason: z.string().optional().describe("Reason for bypassing branch policies. Used for update only when bypassPolicy is true."),
583
584
  reviewerIds: z.array(z.string()).optional().describe("List of reviewer IDs. Required for update_reviewers."),
584
585
  reviewerAction: z.enum(["add", "remove"]).optional().describe("Whether to add or remove reviewers. Required for update_reviewers."),
585
586
  vote: z.enum(["Approved", "ApprovedWithSuggestions", "NoVote", "WaitingForAuthor", "Rejected"]).optional().describe("The vote to cast. Required for vote."),
586
- }, async ({ action, repositoryId, pullRequestId, project, sourceRefName, targetRefName, title, description, isDraft, workItems, forkSourceRepositoryId, labels, status, autoComplete, mergeStrategy, mergeCommitMessage, deleteSourceBranch, transitionWorkItems, bypassReason, reviewerIds, reviewerAction, vote, }) => {
587
+ }, async ({ action, repositoryId, pullRequestId, project, sourceRefName, targetRefName, title, description, isDraft, workItems, forkSourceRepositoryId, labels, status, autoComplete, mergeStrategy, mergeCommitMessage, deleteSourceBranch, transitionWorkItems, bypassPolicy, bypassReason, reviewerIds, reviewerAction, vote, }) => {
587
588
  try {
588
589
  const connection = await connectionProvider();
589
590
  const gitApi = await connection.getGitApi();
@@ -633,18 +634,21 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
633
634
  }
634
635
  if (autoComplete !== undefined) {
635
636
  if (autoComplete) {
637
+ if (bypassPolicy && !bypassReason) {
638
+ return { content: [{ type: "text", text: "bypassReason is required when bypassPolicy is true" }], isError: true };
639
+ }
636
640
  const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
637
641
  updateRequest.autoCompleteSetBy = { id: data.authenticatedUser.id };
638
642
  const completionOptions = {
639
643
  deleteSourceBranch: deleteSourceBranch || false,
640
644
  transitionWorkItems: transitionWorkItems !== false,
641
- bypassPolicy: !!bypassReason,
645
+ bypassPolicy: bypassPolicy === true,
642
646
  };
643
647
  if (mergeStrategy)
644
648
  completionOptions.mergeStrategy = GitPullRequestMergeStrategy[mergeStrategy];
645
649
  if (mergeCommitMessage)
646
650
  completionOptions.mergeCommitMessage = mergeCommitMessage;
647
- if (bypassReason)
651
+ if (bypassPolicy && bypassReason)
648
652
  completionOptions.bypassReason = bypassReason;
649
653
  updateRequest.completionOptions = completionOptions;
650
654
  }
@@ -751,20 +755,21 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
751
755
  // --- repo_pull_request_thread_write ----------------------------------------
752
756
  server.tool(REPO_TOOLS.repo_pull_request_thread_write, "Write operations for pull request comment threads. Use the action parameter to specify the operation.", {
753
757
  action: z
754
- .enum(["create", "reply", "update_status"])
755
- .describe("The action to perform. Options: create (create a new comment thread on a pull request), reply (reply to a comment in a thread), update_status (update the status of a comment thread)."),
758
+ .enum(["create", "reply", "update", "update_status"])
759
+ .describe("The action to perform. Options: create (create a new comment thread on a pull request), reply (reply to a comment in a thread), update (update an existing comment), update_status (update the status of a comment thread)."),
756
760
  repositoryId: z.string().describe("The ID or name of the repository. When using a name instead of a GUID, project must also be provided."),
757
761
  pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request."),
758
762
  project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name instead of a GUID."),
759
- threadId: z.coerce.number().min(1).optional().describe("The ID of the thread. Required for reply and update_status."),
760
- content: z.string().optional().describe("The content of the comment. Required for create and reply."),
763
+ threadId: z.coerce.number().min(1).optional().describe("The ID of the thread. Required for reply, update, and update_status."),
764
+ commentId: z.coerce.number().min(1).optional().describe("The ID of the comment to update. Required for update."),
765
+ content: z.string().optional().describe("The content of the comment. Required for create, reply, and update."),
761
766
  status: z
762
767
  .enum(getEnumKeys(CommentThreadStatus))
763
768
  .optional()
764
769
  .default(CommentThreadStatus[CommentThreadStatus.Active])
765
770
  .describe("The thread status. Used for create (defaults to 'Active') and required for update_status."),
766
771
  filePath: z.string().optional().describe("The file path for the comment thread. Used for create."),
767
- fullResponse: z.boolean().optional().default(false).describe("Return full JSON response. Used for reply."),
772
+ fullResponse: z.boolean().optional().default(false).describe("Return full JSON response. Used for reply and update."),
768
773
  rightFileStartLine: z.coerce.number().min(1).optional().describe("Start line in the right file. Used for create."),
769
774
  rightFileStartOffset: z.number().optional().describe("Start character offset in the right file. Used for create."),
770
775
  rightFileEndLine: z.number().optional().describe("End line in the right file. Used for create."),
@@ -772,7 +777,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
772
777
  changeTrackingId: z.coerce.number().int().min(1).optional().describe("The file change tracking ID from the pull request iteration changes. Used for create."),
773
778
  firstComparingIteration: z.coerce.number().int().min(0).optional().describe("The iteration on the left side of the diff. Used for create."),
774
779
  secondComparingIteration: z.coerce.number().int().min(1).optional().describe("The iteration on the right side of the diff. Used for create."),
775
- }, async ({ action, repositoryId, pullRequestId, project, threadId, content, status, filePath, fullResponse, rightFileStartLine, rightFileStartOffset, rightFileEndLine, rightFileEndOffset, changeTrackingId, firstComparingIteration, secondComparingIteration, }) => {
780
+ }, async ({ action, repositoryId, pullRequestId, project, threadId, commentId, content, status, filePath, fullResponse, rightFileStartLine, rightFileStartOffset, rightFileEndLine, rightFileEndOffset, changeTrackingId, firstComparingIteration, secondComparingIteration, }) => {
776
781
  try {
777
782
  const connection = await connectionProvider();
778
783
  const gitApi = await connection.getGitApi();
@@ -848,6 +853,21 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
848
853
  return { content: [{ type: "text", text: JSON.stringify(comment, null, 2) }] };
849
854
  return { content: [{ type: "text", text: `Comment successfully added to thread ${threadId}.` }] };
850
855
  }
856
+ if (action === "update") {
857
+ if (!threadId)
858
+ return { content: [{ type: "text", text: "threadId is required for update" }], isError: true };
859
+ if (!commentId)
860
+ return { content: [{ type: "text", text: "commentId is required for update" }], isError: true };
861
+ if (!content)
862
+ return { content: [{ type: "text", text: "content is required for update" }], isError: true };
863
+ const comment = await gitApi.updateComment({ content }, repositoryId, pullRequestId, threadId, commentId, project);
864
+ if (!comment) {
865
+ return { content: [{ type: "text", text: `Error: Failed to update comment ${commentId} in thread ${threadId}. The comment was not updated successfully.` }], isError: true };
866
+ }
867
+ if (fullResponse)
868
+ return { content: [{ type: "text", text: JSON.stringify(comment, null, 2) }] };
869
+ return { content: [{ type: "text", text: `Comment ${commentId} successfully updated in thread ${threadId}.` }] };
870
+ }
851
871
  if (action === "update_status") {
852
872
  if (!threadId)
853
873
  return { content: [{ type: "text", text: "threadId is required for update_status" }], isError: true };
@@ -165,7 +165,7 @@ async function fetchCombinedResults(topSearchResults, gitApi) {
165
165
  });
166
166
  continue;
167
167
  }
168
- const versionDescriptor = changeId ? { version: changeId, versionType: 2, versionOptions: 0 } : undefined;
168
+ const versionDescriptor = { version: changeId, versionType: 2, versionOptions: 0 };
169
169
  const item = await gitApi.getItem(repositoryId, filePath, projectId, undefined, VersionControlRecursionType.None, true, // includeContentMetadata
170
170
  false, // latestProcessedChange
171
171
  false, // download
package/dist/tools.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Copyright (c) Microsoft Corporation.
2
2
  // Licensed under the MIT License.
3
+ import { wrapExternalToolResponse } from "./shared/content-safety.js";
3
4
  import { Domain } from "./shared/domains.js";
4
5
  import { configureAdvSecTools } from "./tools/advanced-security.js";
5
6
  import { configureMcpAppsTools } from "./tools/mcp-apps.js";
@@ -14,11 +15,13 @@ import { configureWorkItemTools } from "./tools/work-items.js";
14
15
  function configureAllTools(server, tokenProvider, connectionProvider, userAgentProvider, enabledDomains) {
15
16
  const configureIfDomainEnabled = (domain, configureFn) => {
16
17
  if (enabledDomains.has(domain)) {
17
- configureFn();
18
+ configureToolsWithContentSafety(server, domain, configureFn);
18
19
  }
19
20
  };
20
21
  configureIfDomainEnabled(Domain.CORE, () => configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider));
21
- configureIfDomainEnabled(Domain.MCP_APPS, () => configureMcpAppsTools(server));
22
+ // This is a local health-check response and contains no Azure DevOps content.
23
+ if (enabledDomains.has(Domain.MCP_APPS))
24
+ configureMcpAppsTools(server);
22
25
  configureIfDomainEnabled(Domain.WORK, () => configureWorkTools(server, tokenProvider, connectionProvider));
23
26
  configureIfDomainEnabled(Domain.PIPELINES, () => configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider));
24
27
  configureIfDomainEnabled(Domain.REPOSITORIES, () => configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider));
@@ -28,4 +31,34 @@ function configureAllTools(server, tokenProvider, connectionProvider, userAgentP
28
31
  configureIfDomainEnabled(Domain.SEARCH, () => configureSearchTools(server, tokenProvider, connectionProvider, userAgentProvider));
29
32
  configureIfDomainEnabled(Domain.ADVANCED_SECURITY, () => configureAdvSecTools(server, tokenProvider, connectionProvider));
30
33
  }
34
+ /**
35
+ * Centralizes the untrusted-content boundary for tool responses. Tool registration
36
+ * is synchronous, so the original method is restored before this function returns.
37
+ */
38
+ function configureToolsWithContentSafety(server, domain, configureFn) {
39
+ const originalTool = server.tool;
40
+ const originalRegisterTool = server.registerTool;
41
+ const wrapRegistrationMethod = (registrationMethod) => new Proxy(registrationMethod, {
42
+ apply(target, thisArg, argumentsList) {
43
+ const callbackIndex = argumentsList.length - 1;
44
+ const callback = argumentsList[callbackIndex];
45
+ if (typeof callback === "function") {
46
+ argumentsList[callbackIndex] = async (...callbackArgs) => {
47
+ const response = (await Reflect.apply(callback, undefined, callbackArgs));
48
+ return wrapExternalToolResponse(response, `Azure DevOps ${domain}`);
49
+ };
50
+ }
51
+ return Reflect.apply(target, thisArg, argumentsList);
52
+ },
53
+ });
54
+ server.tool = wrapRegistrationMethod(originalTool);
55
+ server.registerTool = wrapRegistrationMethod(originalRegisterTool);
56
+ try {
57
+ configureFn();
58
+ }
59
+ finally {
60
+ server.tool = originalTool;
61
+ server.registerTool = originalRegisterTool;
62
+ }
63
+ }
31
64
  export { configureAllTools };
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const packageVersion = "2.9.0-nightly.20260831";
1
+ export const packageVersion = "2.9.0-nightly.20260902";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azure-devops/mcp",
3
- "version": "2.9.0-nightly.20260831",
3
+ "version": "2.9.0-nightly.20260902",
4
4
  "mcpName": "microsoft.com/azure-devops",
5
5
  "description": "MCP server for interacting with Azure DevOps",
6
6
  "license": "MIT",