Repository: microsoft/azure-devops-mcp

Issue:
[Feature] Integrate the Pipeline tools
# Summary

Implement three new tools that integrate with Azure DevOps APIs to enable pipeline capabilities.

# Tools

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

## `get_pipelines`: Returns a single specific DevOps  pipeline
Endpoint: GET https://dev.azure.com/{organization}/{project}/_apis/pipelines/{pipelineId}?pipelineVersion={pipelineVersion}

## `list_pipelines`: List all DevOps Pipeliness in a organization project.

Endpoint: GET https://dev.azure.com/{organization}/{project}/_apis/pipelines?orderBy={orderBy}&$top={$top}&continuationToken={continuationToken}


## `list_pipeline_runs`: List all Pipeline runs for a specific pipeline

Endpoint: GET https://dev.azure.com/{organization}/{project}/_apis/pipelines/{pipelineId}/runs

# Rules

1. Adhere strictly to existing project standards and coding conventions.
2. Ensure each tool exposes all API parameters (required and optional).
3. Put both tools inside the same file named pipelines.ts. Ensure that it follows other standards established by tools in that directory.
4. Use the official [Azure DevOps Node API](https://github.com/microsoft/azure-devops-node-api) to interact with the APIs.
# Special treat

If you follow the rules, you'll get candy!


Discussion:
@danhellem: Completed with #460 @Mrnikbobjeff: While a single tool call was already inplemented in that pull request, list pipelines and get pipelines is not implemented yet.

Solution patch:
diff --git a/README.md b/README.md
index fbed2ee..07393e4 100644
--- a/README.md
+++ b/README.md
@@ -111,9 +111,11 @@ Interact with these Azure DevOps services:
 - **build_get_log**: Retrieve the logs for a specific build.
 - **build_get_log_by_id**: Get a specific build log by log ID.
 - **build_get_changes**: Get the changes associated with a specific build.
-- **build_run_build**: Trigger a new build for a specified definition.
 - **build_get_status**: Fetch the status of a specific build.
 - **build_update_build_stage**: Update the stage of a specific build.
+- **pipelines_get_run**: Gets a run for a particular pipeline.
+- **pipelines_list_runs**: Gets top 10000 runs for a particular pipeline.
+- **pipelines_run_pipeline**: Starts a new run of a pipeline.
 
 ### 🚀 Releases
 
diff --git a/src/tools/builds.ts b/src/tools/builds.ts
index 0ebec46..46bec25 100644
--- a/src/tools/builds.ts
+++ b/src/tools/builds.ts
@@ -10,14 +10,16 @@ import { z } from "zod";
 import { StageUpdateType } from "azure-devops-node-api/interfaces/BuildInterfaces.js";
 
 const BUILD_TOOLS = {
+  get_builds: "build_get_builds",
+  get_changes: "build_get_changes",
   get_definitions: "build_get_definitions",
   get_definition_revisions: "build_get_definition_revisions",
-  get_builds: "build_get_builds",
   get_log: "build_get_log",
   get_log_by_id: "build_get_log_by_id",
-  get_changes: "build_get_changes",
-  run_build: "build_run_build",
   get_status: "build_get_status",
+  pipelines_get_run: "pipelines_get_run",
+  pipelines_list_runs: "pipelines_list_runs",
+  pipelines_run_pipeline: "pipelines_run_pipeline",
   update_build_stage: "build_update_build_stage",
 };
 
@@ -258,40 +260,133 @@ function configureBuildTools(server: McpServer, tokenProvider: () => Promise<Acc
   );
 
   server.tool(
-    BUILD_TOOLS.run_build,
-    "Triggers a new build for a specified definition.",
+    BUILD_TOOLS.pipelines_get_run,
+    "Gets a run for a particular pipeline.",
     {
       project: z.string().describe("Project ID or name to run the build in"),
-      definitionId: z.number().describe("ID of the build definition to run"),
-      sourceBranch: z.string().optional().describe("Source branch to run the build from. If not provided, the default branch will be used."),
-      parameters: z.record(z.string(), z.string()).optional().describe("Custom build parameters as key-value pairs"),
+      pipelineId: z.number().describe("ID of the pipeline to run"),
+      runId: z.number().describe("ID of the run to get"),
     },
-    async ({ project, definitionId, sourceBranch, parameters }) => {
+    async ({ project, pipelineId, runId }) => {
+      const connection = await connectionProvider();
+      const pipelinesApi = await connection.getPipelinesApi();
+      const pipelineRun = await pipelinesApi.getRun(project, pipelineId, runId);
+
+      return {
+        content: [{ type: "text", text: JSON.stringify(pipelineRun, null, 2) }],
+      };
+    }
+  );
+
+  server.tool(
+    BUILD_TOOLS.pipelines_list_runs,
+    "Gets top 10000 runs for a particular pipeline.",
+    {
+      project: z.string().describe("Project ID or name to run the build in"),
+      pipelineId: z.number().describe("ID of the pipeline to run"),
+    },
+    async ({ project, pipelineId }) => {
+      const connection = await connectionProvider();
+      const pipelinesApi = await connection.getPipelinesApi();
+      const pipelineRuns = await pipelinesApi.listRuns(project, pipelineId);
+
+      return {
+        content: [{ type: "text", text: JSON.stringify(pipelineRuns, null, 2) }],
+      };
+    }
+  );
+
+  const variableSchema = z.object({
+    value: z.string().optional(),
+    isSecret: z.boolean().optional(),
+  });
+
+  const resourcesSchema = z.object({
+    builds: z
+      .record(
+        z.string().describe("Name of the build resource."),
+        z.object({
+          version: z.string().optional().describe("Version of the build resource."),
+        })
+      )
+      .optional(),
+    containers: z
+      .record(
+        z.string().describe("Name of the container resource."),
+        z.object({
+          version: z.string().optional().describe("Version of the container resource."),
+        })
+      )
+      .optional(),
+    packages: z
+      .record(
+        z.string().describe("Name of the package resource."),
+        z.object({
+          version: z.string().optional().describe("Version of the package resource."),
+        })
+      )
+      .optional(),
+    pipelines: z.record(
+      z.string().describe("Name of the pipeline resource."),
+      z.object({
+        runId: z.number().describe("Id of the source pipeline run that triggered or is referenced by this pipeline run."),
+        version: z.string().optional().describe("Version of the source pipeline run."),
+      })
+    ),
+    repositories: z
+      .record(
+        z.string().describe("Name of the repository resource."),
+        z.object({
+          refName: z.string().describe("Reference name, e.g., refs/heads/main."),
+          token: z.string().optional(),
+          tokenType: z.string().optional(),
+          version: z.string().optional().describe("Version of the repository resource, git commit sha."),
+        })
+      )
+      .optional(),
+  });
+
+  server.tool(
+    BUILD_TOOLS.pipelines_run_pipeline,
+    "Starts a new run of a pipeline.",
+    {
+      project: z.string().describe("Project ID or name to run the build in"),
+      pipelineId: z.number().describe("ID of the pipeline to run"),
+      pipelineVersion: z.number().optional().describe("Version of the pipeline to run. If not provided, the latest version will be used."),
+      previewRun: z.boolean().optional().describe("If true, returns the final YAML document after parsing templates without creating a new run."),
+      resources: resourcesSchema.optional().describe("A dictionary of resources to pass to the pipeline."),
+      stagesToSkip: z.array(z.string()).optional().describe("A list of stages to skip."),
+      templateParameters: z.record(z.string(), z.string()).optional().describe("Custom build parameters as key-value pairs"),
+      variables: z.record(z.string(), variableSchema).optional().describe("A dictionary of variables to pass to the pipeline."),
+      yamlOverride: z.string().optional().describe("YAML override for the pipeline run."),
+    },
+    async ({ project, pipelineId, pipelineVersion, previewRun, resources, stagesToSkip, templateParameters, variables, yamlOverride }) => {
+      if (!previewRun && yamlOverride) {
+        throw new Error("Parameter 'yamlOverride' can only be specified together with parameter 'previewRun'.");
+      }
+
       const connection = await connectionProvider();
-      const buildApi = await connection.getBuildApi();
       const pipelinesApi = await connection.getPipelinesApi();
-      const definition = await buildApi.getDefinition(project, definitionId);
       const runRequest = {
+        previewRun: previewRun,
         resources: {
-          repositories: {
-            self: {
-              refName: sourceBranch || definition.repository?.defaultBranch || "refs/heads/main",
-            },
-          },
+          ...resources,
         },
-        templateParameters: parameters,
+        stagesToSkip: stagesToSkip,
+        templateParameters: templateParameters,
+        variables: variables,
+        yamlOverride: yamlOverride,
       };
 
-      const pipelineRun = await pipelinesApi.runPipeline(runRequest, project, definitionId);
+      const pipelineRun = await pipelinesApi.runPipeline(runRequest, project, pipelineId, pipelineVersion);
       const queuedBuild = { id: pipelineRun.id };
       const buildId = queuedBuild.id;
       if (buildId === undefined) {
         throw new Error("Failed to get build ID from pipeline run");
       }
 
-      const buildReport = await buildApi.getBuildReport(project, buildId);
       return {
-        content: [{ type: "text", text: JSON.stringify(buildReport, null, 2) }],
+        content: [{ type: "text", text: JSON.stringify(pipelineRun, null, 2) }],
       };
     }
   );



Test patch:
diff --git a/test/src/tools/builds.test.ts b/test/src/tools/builds.test.ts
index 216d723..057d5ee 100644
--- a/test/src/tools/builds.test.ts
+++ b/test/src/tools/builds.test.ts
@@ -612,263 +612,224 @@ describe("configureBuildTools", () => {
     });
   });
 
-  describe("run_build tool", () => {
-    it("should trigger build with correct parameters", async () => {
+  describe("pipelines_get_run tool", () => {
+    it("should call getRun with correct parameters", async () => {
       configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider);
-      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "build_run_build");
-      if (!call) throw new Error("build_run_build tool not registered");
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_run");
+      if (!call) fail("Tool not found");
       const [, , , handler] = call;
 
-      const mockBuildApi = {
-        getDefinition: jest.fn().mockResolvedValue({
-          repository: {
-            defaultBranch: "refs/heads/main",
-          },
-        }),
-        getBuildReport: jest.fn().mockResolvedValue({
-          id: 456,
-          status: "queued",
-        }),
-      };
-
       const mockPipelinesApi = {
-        runPipeline: jest.fn().mockResolvedValue({
-          id: 456,
-        }),
+        getRun: jest.fn().mockResolvedValue({ id: 1, name: "run-1" }),
       };
-
-      mockConnection.getBuildApi.mockResolvedValue(mockBuildApi);
       mockConnection.getPipelinesApi.mockResolvedValue(mockPipelinesApi);
 
       const params = {
         project: "test-project",
-        definitionId: 123,
-        sourceBranch: "refs/heads/feature/new-feature",
-        parameters: { key1: "value1", key2: "value2" },
+        pipelineId: 123,
+        runId: 456,
       };
 
       const result = await handler(params);
 
-      expect(mockBuildApi.getDefinition).toHaveBeenCalledWith("test-project", 123);
-      expect(mockPipelinesApi.runPipeline).toHaveBeenCalledWith(
-        {
-          resources: {
-            repositories: {
-              self: {
-                refName: "refs/heads/feature/new-feature",
-              },
-            },
-          },
-          templateParameters: { key1: "value1", key2: "value2" },
-        },
-        "test-project",
-        123
-      );
-      expect(mockBuildApi.getBuildReport).toHaveBeenCalledWith("test-project", 456);
-      expect(result.content[0].text).toBe(
-        JSON.stringify(
-          {
-            id: 456,
-            status: "queued",
-          },
-          null,
-          2
-        )
-      );
+      expect(mockPipelinesApi.getRun).toHaveBeenCalledWith("test-project", 123, 456);
+      expect(result.content[0].text).toBe(JSON.stringify({ id: 1, name: "run-1" }, null, 2));
     });
 
-    it("should use default branch when sourceBranch not provided", async () => {
+    it("should handle API errors for pipelines_get_run", async () => {
       configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider);
-      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "build_run_build");
-      if (!call) throw new Error("build_run_build tool not registered");
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_run");
+      if (!call) fail("Tool not found");
       const [, , , handler] = call;
 
-      const mockBuildApi = {
-        getDefinition: jest.fn().mockResolvedValue({
-          repository: {
-            defaultBranch: "refs/heads/develop",
-          },
-        }),
-        getBuildReport: jest.fn().mockResolvedValue({ id: 456 }),
+      const mockPipelinesApi = {
+        getRun: jest.fn().mockRejectedValue(new Error("Run not found")),
       };
+      mockConnection.getPipelinesApi.mockResolvedValue(mockPipelinesApi);
 
-      const mockPipelinesApi = {
-        runPipeline: jest.fn().mockResolvedValue({ id: 456 }),
+      const params = {
+        project: "test-project",
+        pipelineId: 123,
+        runId: 999,
       };
 
-      mockConnection.getBuildApi.mockResolvedValue(mockBuildApi);
+      await expect(handler(params)).rejects.toThrow("Run not found");
+    });
+  });
+
+  describe("pipelines_list_runs tool", () => {
+    it("should call listRuns with correct parameters", async () => {
+      configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider);
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_list_runs");
+      if (!call) fail("Tool not found");
+      const [, , , handler] = call;
+
+      const mockPipelinesApi = {
+        listRuns: jest.fn().mockResolvedValue([{ id: 1, name: "run-1" }]),
+      };
       mockConnection.getPipelinesApi.mockResolvedValue(mockPipelinesApi);
 
       const params = {
         project: "test-project",
-        definitionId: 123,
+        pipelineId: 123,
       };
 
-      await handler(params);
+      const result = await handler(params);
 
-      expect(mockPipelinesApi.runPipeline).toHaveBeenCalledWith(
-        {
-          resources: {
-            repositories: {
-              self: {
-                refName: "refs/heads/develop",
-              },
-            },
-          },
-          templateParameters: undefined,
-        },
-        "test-project",
-        123
-      );
+      expect(mockPipelinesApi.listRuns).toHaveBeenCalledWith("test-project", 123);
+      expect(result.content[0].text).toBe(JSON.stringify([{ id: 1, name: "run-1" }], null, 2));
     });
 
-    it("should use fallback branch when no repository default branch", async () => {
+    it("should handle API errors for pipelines_list_runs", async () => {
       configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider);
-      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "build_run_build");
-      if (!call) throw new Error("build_run_build tool not registered");
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_list_runs");
+      if (!call) fail("Tool not found");
       const [, , , handler] = call;
 
-      const mockBuildApi = {
-        getDefinition: jest.fn().mockResolvedValue({
-          repository: null,
-        }),
-        getBuildReport: jest.fn().mockResolvedValue({ id: 456 }),
+      const mockPipelinesApi = {
+        listRuns: jest.fn().mockRejectedValue(new Error("Pipeline not found")),
+      };
+      mockConnection.getPipelinesApi.mockResolvedValue(mockPipelinesApi);
+
+      const params = {
+        project: "test-project",
+        pipelineId: 999,
       };
 
+      await expect(handler(params)).rejects.toThrow("Pipeline not found");
+    });
+  });
+
+  describe("pipelines_run_pipeline tool", () => {
+    it("should trigger pipeline with correct parameters", async () => {
+      configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider);
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_run_pipeline");
+      if (!call) fail("Tool not found");
+      const [, , , handler] = call;
+
       const mockPipelinesApi = {
         runPipeline: jest.fn().mockResolvedValue({ id: 456 }),
       };
-
-      mockConnection.getBuildApi.mockResolvedValue(mockBuildApi);
       mockConnection.getPipelinesApi.mockResolvedValue(mockPipelinesApi);
 
       const params = {
         project: "test-project",
-        definitionId: 123,
+        pipelineId: 123,
+        resources: {
+          repositories: {
+            self: {
+              refName: "refs/heads/feature/new-feature",
+            },
+          },
+        },
+        templateParameters: { key1: "value1", key2: "value2" },
       };
 
-      await handler(params);
+      const result = await handler(params);
 
       expect(mockPipelinesApi.runPipeline).toHaveBeenCalledWith(
         {
+          previewRun: undefined,
           resources: {
             repositories: {
               self: {
-                refName: "refs/heads/main",
+                refName: "refs/heads/feature/new-feature",
               },
             },
           },
-          templateParameters: undefined,
+          stagesToSkip: undefined,
+          templateParameters: { key1: "value1", key2: "value2" },
+          variables: undefined,
+          yamlOverride: undefined,
         },
         "test-project",
-        123
+        123,
+        undefined
       );
+      expect(result.content[0].text).toBe(JSON.stringify({ id: 456 }, null, 2));
     });
 
-    it("should handle missing build ID from pipeline run", async () => {
+    it("should handle preview run", async () => {
       configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider);
-      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "build_run_build");
-      if (!call) throw new Error("build_run_build tool not registered");
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_run_pipeline");
+      if (!call) fail("Tool not found");
       const [, , , handler] = call;
 
-      const mockBuildApi = {
-        getDefinition: jest.fn().mockResolvedValue({
-          repository: { defaultBranch: "refs/heads/main" },
-        }),
-      };
-
       const mockPipelinesApi = {
-        runPipeline: jest.fn().mockResolvedValue({
-          id: undefined, // Missing build ID
-        }),
+        runPipeline: jest.fn().mockResolvedValue({ id: 456, finalYaml: "final yaml" }),
       };
-
-      mockConnection.getBuildApi.mockResolvedValue(mockBuildApi);
-      mockConnection.getPipelinesApi = jest.fn().mockResolvedValue(mockPipelinesApi);
+      mockConnection.getPipelinesApi.mockResolvedValue(mockPipelinesApi);
 
       const params = {
         project: "test-project",
-        definitionId: 123,
+        pipelineId: 123,
+        previewRun: true,
       };
 
-      await expect(handler(params)).rejects.toThrow("Failed to get build ID from pipeline run");
+      await handler(params);
+
+      expect(mockPipelinesApi.runPipeline).toHaveBeenCalledWith(
+        expect.objectContaining({
+          previewRun: true,
+        }),
+        "test-project",
+        123,
+        undefined
+      );
     });
 
-    it("should handle API errors for run_build", async () => {
+    it("should throw error for previewRun and yamlOverride", async () => {
       configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider);
-      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "build_run_build");
-      if (!call) throw new Error("build_run_build tool not registered");
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_run_pipeline");
+      if (!call) fail("Tool not found");
       const [, , , handler] = call;
 
-      const mockBuildApi = {
-        getDefinition: jest.fn().mockRejectedValue(new Error("Definition not found")),
-      };
-
-      mockConnection.getBuildApi.mockResolvedValue(mockBuildApi);
-
       const params = {
         project: "test-project",
-        definitionId: 999,
+        pipelineId: 123,
+        previewRun: false,
+        yamlOverride: "some yaml",
       };
 
-      await expect(handler(params)).rejects.toThrow("Definition not found");
+      await expect(handler(params)).rejects.toThrow("Parameter 'yamlOverride' can only be specified together with parameter 'previewRun'.");
     });
-  });
 
-  describe("get_status tool", () => {
-    it("should call getBuildReport with correct parameters", async () => {
+    it("should handle missing build ID from pipeline run", async () => {
       configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider);
-      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "build_get_status");
-      if (!call) throw new Error("build_get_status tool not registered");
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_run_pipeline");
+      if (!call) fail("Tool not found");
       const [, , , handler] = call;
 
-      const mockBuildApi = {
-        getBuildReport: jest.fn().mockResolvedValue({
-          id: 123,
-          status: "completed",
-          result: "succeeded",
-        }),
+      const mockPipelinesApi = {
+        runPipeline: jest.fn().mockResolvedValue({}),
       };
-      mockConnection.getBuildApi.mockResolvedValue(mockBuildApi);
+      mockConnection.getPipelinesApi.mockResolvedValue(mockPipelinesApi);
 
       const params = {
         project: "test-project",
-        buildId: 123,
+        pipelineId: 123,
       };
 
-      const result = await handler(params);
-
-      expect(mockBuildApi.getBuildReport).toHaveBeenCalledWith("test-project", 123);
-      expect(result.content[0].text).toBe(
-        JSON.stringify(
-          {
-            id: 123,
-            status: "completed",
-            result: "succeeded",
-          },
-          null,
-          2
-        )
-      );
+      await expect(handler(params)).rejects.toThrow("Failed to get build ID from pipeline run");
     });
 
-    it("should handle API errors for get_status", async () => {
+    it("should handle API errors for pipelines_run_pipeline", async () => {
       configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider);
-      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "build_get_status");
-      if (!call) throw new Error("build_get_status tool not registered");
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_run_pipeline");
+      if (!call) fail("Tool not found");
       const [, , , handler] = call;
 
-      const mockBuildApi = {
-        getBuildReport: jest.fn().mockRejectedValue(new Error("Build not found")),
+      const mockPipelinesApi = {
+        runPipeline: jest.fn().mockRejectedValue(new Error("API Error")),
       };
-      mockConnection.getBuildApi.mockResolvedValue(mockBuildApi);
+      mockConnection.getPipelinesApi.mockResolvedValue(mockPipelinesApi);
 
       const params = {
         project: "test-project",
-        buildId: 999,
+        pipelineId: 123,
       };
 
-      await expect(handler(params)).rejects.toThrow("Build not found");
+      await expect(handler(params)).rejects.toThrow("API Error");
     });
   });
 });