Repository: microsoft/azure-devops-mcp

Issue:
[Feature]: Add create branch tool
# Summary

Implement a new tool that integrate with Azure DevOps APIs to create a new branch based on a specified one.

# Tools

Develop the following tools with full parameter support, including optional ones:

## `repo_create_branch`: Create a new branch.

Endpoint: POST https://dev.azure.com/{organization}/{project}/_apis/git/repositories/{repositoryId}/pushes?api-version=7.2-preview.1

# Rules

1. Adhere strictly to existing project standards and coding conventions.
2. Ensure each tool exposes all API parameters (required and optional).
3. Use the official [Azure DevOps Node API](https://github.com/microsoft/azure-devops-node-api) to interact with the APIs.


Discussion:

Solution patch:
diff --git a/README.md b/README.md
index baafc4e..50ed228 100644
--- a/README.md
+++ b/README.md
@@ -94,6 +94,7 @@ Interact with these Azure DevOps services:
 - **repo_get_branch_by_name**: Get a branch by its name.
 - **repo_get_pull_request_by_id**: Get a pull request by its ID.
 - **repo_create_pull_request**: Create a new pull request.
+- **repo_create_branch**: Create a new branch in the repository.
 - **repo_update_pull_request_status**: Update the status of an existing pull request to active or abandoned.
 - **repo_update_pull_request**: Update various fields of an existing pull request (title, description, draft status, target branch).
 - **repo_update_pull_request_reviewers**: Add or remove reviewers for an existing pull request.
diff --git a/src/tools/repositories.ts b/src/tools/repositories.ts
index 1a643f8..6e56215 100644
--- a/src/tools/repositories.ts
+++ b/src/tools/repositories.ts
@@ -33,6 +33,7 @@ const REPO_TOOLS = {
   get_branch_by_name: "repo_get_branch_by_name",
   get_pull_request_by_id: "repo_get_pull_request_by_id",
   create_pull_request: "repo_create_pull_request",
+  create_branch: "repo_create_branch",
   update_pull_request: "repo_update_pull_request",
   update_pull_request_reviewers: "repo_update_pull_request_reviewers",
   reply_to_comment: "repo_reply_to_comment",
@@ -142,6 +143,99 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<stri
     }
   );
 
+  server.tool(
+    REPO_TOOLS.create_branch,
+    "Create a new branch in the repository.",
+    {
+      repositoryId: z.string().describe("The ID of the repository where the branch will be created."),
+      branchName: z.string().describe("The name of the new branch to create, e.g., 'feature-branch'."),
+      sourceBranchName: z.string().optional().default("main").describe("The name of the source branch to create the new branch from. Defaults to 'main'."),
+      sourceCommitId: z.string().optional().describe("The commit ID to create the branch from. If not provided, uses the latest commit of the source branch."),
+    },
+    async ({ repositoryId, branchName, sourceBranchName, sourceCommitId }) => {
+      const connection = await connectionProvider();
+      const gitApi = await connection.getGitApi();
+
+      let commitId = sourceCommitId;
+
+      // If no commit ID is provided, get the latest commit from the source branch
+      if (!commitId) {
+        const sourceRefName = `refs/heads/${sourceBranchName}`;
+        try {
+          const sourceBranch = await gitApi.getRefs(repositoryId, undefined, "heads/", false, false, undefined, false, undefined, sourceBranchName);
+          const branch = sourceBranch.find((b) => b.name === sourceRefName);
+          if (!branch || !branch.objectId) {
+            return {
+              content: [
+                {
+                  type: "text",
+                  text: `Error: Source branch '${sourceBranchName}' not found in repository ${repositoryId}`,
+                },
+              ],
+              isError: true,
+            };
+          }
+          commitId = branch.objectId;
+        } catch (error) {
+          return {
+            content: [
+              {
+                type: "text",
+                text: `Error retrieving source branch '${sourceBranchName}': ${error instanceof Error ? error.message : String(error)}`,
+              },
+            ],
+            isError: true,
+          };
+        }
+      }
+
+      // Create the new branch using updateRefs
+      const newRefName = `refs/heads/${branchName}`;
+      const refUpdate = {
+        name: newRefName,
+        newObjectId: commitId,
+        oldObjectId: "0000000000000000000000000000000000000000", // All zeros indicates creating a new ref
+      };
+
+      try {
+        const result = await gitApi.updateRefs([refUpdate], repositoryId);
+
+        // Check if the branch creation was successful
+        if (result && result.length > 0 && result[0].success) {
+          return {
+            content: [
+              {
+                type: "text",
+                text: `Branch '${branchName}' created successfully from '${sourceBranchName}' (${commitId})`,
+              },
+            ],
+          };
+        } else {
+          const errorMessage = result && result.length > 0 && result[0].customMessage ? result[0].customMessage : "Unknown error occurred during branch creation";
+          return {
+            content: [
+              {
+                type: "text",
+                text: `Error creating branch '${branchName}': ${errorMessage}`,
+              },
+            ],
+            isError: true,
+          };
+        }
+      } catch (error) {
+        return {
+          content: [
+            {
+              type: "text",
+              text: `Error creating branch '${branchName}': ${error instanceof Error ? error.message : String(error)}`,
+            },
+          ],
+          isError: true,
+        };
+      }
+    }
+  );
+
   server.tool(
     REPO_TOOLS.update_pull_request,
     "Update a Pull Request by ID with specified fields.",



Test patch:
diff --git a/test/src/tools/repositories.test.ts b/test/src/tools/repositories.test.ts
index cfd0d06..92b232a 100644
--- a/test/src/tools/repositories.test.ts
+++ b/test/src/tools/repositories.test.ts
@@ -39,6 +39,7 @@ describe("repos tools", () => {
     updateThread: jest.MockedFunction<(...args: unknown[]) => Promise<unknown>>;
     getCommits: jest.MockedFunction<(...args: unknown[]) => Promise<unknown>>;
     getPullRequestQuery: jest.MockedFunction<(...args: unknown[]) => Promise<unknown>>;
+    updateRefs: jest.MockedFunction<(...args: unknown[]) => Promise<unknown>>;
   };
 
   beforeEach(() => {
@@ -64,6 +65,7 @@ describe("repos tools", () => {
       updateThread: jest.fn(),
       getCommits: jest.fn(),
       getPullRequestQuery: jest.fn(),
+      updateRefs: jest.fn(),
     };
 
     connectionProvider = jest.fn().mockResolvedValue({
@@ -351,6 +353,311 @@ describe("repos tools", () => {
     });
   });
 
+  describe("repo_create_branch", () => {
+    it("should create branch with default source branch (main)", async () => {
+      configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
+
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch);
+      if (!call) throw new Error("repo_create_branch tool not registered");
+      const [, , , handler] = call;
+
+      const mockSourceBranch = [
+        {
+          name: "refs/heads/main",
+          objectId: "abc123def456",
+        },
+      ];
+      const mockUpdateResult = [
+        {
+          success: true,
+          updateStatus: 0,
+        },
+      ];
+
+      mockGitApi.getRefs.mockResolvedValue(mockSourceBranch);
+      mockGitApi.updateRefs.mockResolvedValue(mockUpdateResult);
+
+      const params = {
+        repositoryId: "repo123",
+        branchName: "feature-branch",
+        sourceBranchName: "main",
+      };
+
+      const result = await handler(params);
+
+      expect(mockGitApi.getRefs).toHaveBeenCalledWith("repo123", undefined, "heads/", false, false, undefined, false, undefined, "main");
+      expect(mockGitApi.updateRefs).toHaveBeenCalledWith(
+        [
+          {
+            name: "refs/heads/feature-branch",
+            newObjectId: "abc123def456",
+            oldObjectId: "0000000000000000000000000000000000000000",
+          },
+        ],
+        "repo123"
+      );
+
+      expect(result.content[0].text).toBe("Branch 'feature-branch' created successfully from 'main' (abc123def456)");
+    });
+
+    it("should create branch with custom source branch", async () => {
+      configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
+
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch);
+      if (!call) throw new Error("repo_create_branch tool not registered");
+      const [, , , handler] = call;
+
+      const mockSourceBranch = [
+        {
+          name: "refs/heads/develop",
+          objectId: "def456ghi789",
+        },
+      ];
+      const mockUpdateResult = [
+        {
+          success: true,
+          updateStatus: 0,
+        },
+      ];
+
+      mockGitApi.getRefs.mockResolvedValue(mockSourceBranch);
+      mockGitApi.updateRefs.mockResolvedValue(mockUpdateResult);
+
+      const params = {
+        repositoryId: "repo123",
+        branchName: "feature-branch",
+        sourceBranchName: "develop",
+      };
+
+      const result = await handler(params);
+
+      expect(mockGitApi.getRefs).toHaveBeenCalledWith("repo123", undefined, "heads/", false, false, undefined, false, undefined, "develop");
+      expect(mockGitApi.updateRefs).toHaveBeenCalledWith(
+        [
+          {
+            name: "refs/heads/feature-branch",
+            newObjectId: "def456ghi789",
+            oldObjectId: "0000000000000000000000000000000000000000",
+          },
+        ],
+        "repo123"
+      );
+
+      expect(result.content[0].text).toBe("Branch 'feature-branch' created successfully from 'develop' (def456ghi789)");
+    });
+
+    it("should create branch with specific commit ID", async () => {
+      configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
+
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch);
+      if (!call) throw new Error("repo_create_branch tool not registered");
+      const [, , , handler] = call;
+
+      const mockUpdateResult = [
+        {
+          success: true,
+          updateStatus: 0,
+        },
+      ];
+
+      mockGitApi.updateRefs.mockResolvedValue(mockUpdateResult);
+
+      const params = {
+        repositoryId: "repo123",
+        branchName: "feature-branch",
+        sourceBranchName: "main",
+        sourceCommitId: "xyz789abc123",
+      };
+
+      const result = await handler(params);
+
+      // Should not call getRefs when sourceCommitId is provided
+      expect(mockGitApi.getRefs).not.toHaveBeenCalled();
+      expect(mockGitApi.updateRefs).toHaveBeenCalledWith(
+        [
+          {
+            name: "refs/heads/feature-branch",
+            newObjectId: "xyz789abc123",
+            oldObjectId: "0000000000000000000000000000000000000000",
+          },
+        ],
+        "repo123"
+      );
+
+      expect(result.content[0].text).toBe("Branch 'feature-branch' created successfully from 'main' (xyz789abc123)");
+    });
+
+    it("should handle source branch not found error", async () => {
+      configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
+
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch);
+      if (!call) throw new Error("repo_create_branch tool not registered");
+      const [, , , handler] = call;
+
+      mockGitApi.getRefs.mockResolvedValue([]);
+
+      const params = {
+        repositoryId: "repo123",
+        branchName: "feature-branch",
+        sourceBranchName: "nonexistent",
+      };
+
+      const result = await handler(params);
+
+      expect(result.isError).toBe(true);
+      expect(result.content[0].text).toBe("Error: Source branch 'nonexistent' not found in repository repo123");
+    });
+
+    it("should handle getRefs API error", async () => {
+      configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
+
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch);
+      if (!call) throw new Error("repo_create_branch tool not registered");
+      const [, , , handler] = call;
+
+      const mockError = new Error("API Error");
+      mockGitApi.getRefs.mockRejectedValue(mockError);
+
+      const params = {
+        repositoryId: "repo123",
+        branchName: "feature-branch",
+        sourceBranchName: "main",
+      };
+
+      const result = await handler(params);
+
+      expect(result.isError).toBe(true);
+      expect(result.content[0].text).toBe("Error retrieving source branch 'main': API Error");
+    });
+
+    it("should handle updateRefs failure", async () => {
+      configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
+
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch);
+      if (!call) throw new Error("repo_create_branch tool not registered");
+      const [, , , handler] = call;
+
+      const mockSourceBranch = [
+        {
+          name: "refs/heads/main",
+          objectId: "abc123def456",
+        },
+      ];
+      const mockUpdateResult = [
+        {
+          success: false,
+          customMessage: "Branch already exists",
+        },
+      ];
+
+      mockGitApi.getRefs.mockResolvedValue(mockSourceBranch);
+      mockGitApi.updateRefs.mockResolvedValue(mockUpdateResult);
+
+      const params = {
+        repositoryId: "repo123",
+        branchName: "existing-branch",
+        sourceBranchName: "main",
+      };
+
+      const result = await handler(params);
+
+      expect(result.isError).toBe(true);
+      expect(result.content[0].text).toBe("Error creating branch 'existing-branch': Branch already exists");
+    });
+
+    it("should handle updateRefs failure without custom message", async () => {
+      configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
+
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch);
+      if (!call) throw new Error("repo_create_branch tool not registered");
+      const [, , , handler] = call;
+
+      const mockSourceBranch = [
+        {
+          name: "refs/heads/main",
+          objectId: "abc123def456",
+        },
+      ];
+      const mockUpdateResult = [
+        {
+          success: false,
+        },
+      ];
+
+      mockGitApi.getRefs.mockResolvedValue(mockSourceBranch);
+      mockGitApi.updateRefs.mockResolvedValue(mockUpdateResult);
+
+      const params = {
+        repositoryId: "repo123",
+        branchName: "failing-branch",
+        sourceBranchName: "main",
+      };
+
+      const result = await handler(params);
+
+      expect(result.isError).toBe(true);
+      expect(result.content[0].text).toBe("Error creating branch 'failing-branch': Unknown error occurred during branch creation");
+    });
+
+    it("should handle updateRefs API error", async () => {
+      configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
+
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch);
+      if (!call) throw new Error("repo_create_branch tool not registered");
+      const [, , , handler] = call;
+
+      const mockSourceBranch = [
+        {
+          name: "refs/heads/main",
+          objectId: "abc123def456",
+        },
+      ];
+      const mockError = new Error("Update API Error");
+
+      mockGitApi.getRefs.mockResolvedValue(mockSourceBranch);
+      mockGitApi.updateRefs.mockRejectedValue(mockError);
+
+      const params = {
+        repositoryId: "repo123",
+        branchName: "feature-branch",
+        sourceBranchName: "main",
+      };
+
+      const result = await handler(params);
+
+      expect(result.isError).toBe(true);
+      expect(result.content[0].text).toBe("Error creating branch 'feature-branch': Update API Error");
+    });
+
+    it("should handle source branch with missing objectId", async () => {
+      configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
+
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch);
+      if (!call) throw new Error("repo_create_branch tool not registered");
+      const [, , , handler] = call;
+
+      const mockSourceBranch = [
+        {
+          name: "refs/heads/main",
+          // objectId is missing
+        },
+      ];
+
+      mockGitApi.getRefs.mockResolvedValue(mockSourceBranch);
+
+      const params = {
+        repositoryId: "repo123",
+        branchName: "feature-branch",
+        sourceBranchName: "main",
+      };
+
+      const result = await handler(params);
+
+      expect(result.isError).toBe(true);
+      expect(result.content[0].text).toBe("Error: Source branch 'main' not found in repository repo123");
+    });
+  });
+
   describe("repo_update_pull_request_reviewers", () => {
     it("should add reviewers to pull request", async () => {
       configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);