Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/TOOLSET.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,19 @@ Updates the stage of a specific build.
- **Required**: `project`, `buildId`, `stageName`, `status`
- **Optional**: `forceRetryAllJobs`

### mcp_ado_pipelines_list_artifacts

Lists artifacts for a given build.

- **Required**: `project`, `buildId`

### mcp_ado_pipelines_download_artifact

Downloads a pipeline artifact.

- **Required**: `project`, `buildId`, `artifactName`
- **Optional**: `destinationPath`

## Repositories

### mcp_ado_repo_list_repos_by_project
Expand Down
89 changes: 89 additions & 0 deletions src/tools/pipelines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { BuildQueryOrder, DefinitionQueryOrder } from "azure-devops-node-api/int
import { z } from "zod";
import { StageUpdateType } from "azure-devops-node-api/interfaces/BuildInterfaces.js";
import { ConfigurationType, RepositoryType } from "azure-devops-node-api/interfaces/PipelinesInterfaces.js";
import { mkdirSync, createWriteStream } from "fs";
import { join, resolve } from "path";

const PIPELINE_TOOLS = {
pipelines_get_builds: "pipelines_get_builds",
Expand All @@ -22,6 +24,8 @@ const PIPELINE_TOOLS = {
pipelines_get_run: "pipelines_get_run",
pipelines_list_runs: "pipelines_list_runs",
pipelines_run_pipeline: "pipelines_run_pipeline",
pipelines_list_artifacts: "pipelines_list_artifacts",
pipelines_download_artifact: "pipelines_download_artifact",
};

function configurePipelineTools(server: McpServer, tokenProvider: () => Promise<string>, connectionProvider: () => Promise<WebApi>, userAgentProvider: () => string) {
Expand Down Expand Up @@ -507,6 +511,91 @@ function configurePipelineTools(server: McpServer, tokenProvider: () => Promise<
};
}
);

server.tool(
PIPELINE_TOOLS.pipelines_list_artifacts,
"Lists artifacts for a given build.",
{
project: z.string().describe("The name or ID of the project."),
buildId: z.number().describe("The ID of the build."),
},
async ({ project, buildId }) => {
const connection = await connectionProvider();
const buildApi = await connection.getBuildApi();
const artifacts = await buildApi.getArtifacts(project, buildId);

return {
content: [{ type: "text", text: JSON.stringify(artifacts, null, 2) }],
};
}
);

server.tool(
PIPELINE_TOOLS.pipelines_download_artifact,
"Downloads a pipeline artifact.",
{
project: z.string().describe("The name or ID of the project."),
buildId: z.number().describe("The ID of the build."),
artifactName: z.string().describe("The name of the artifact to download."),
destinationPath: z.string().optional().describe("The local path to download the artifact to. If not provided, returns binary content as base64."),
},
async ({ project, buildId, artifactName, destinationPath }) => {
const connection = await connectionProvider();
const buildApi = await connection.getBuildApi();
const artifact = await buildApi.getArtifact(project, buildId, artifactName);

if (!artifact) {
return {
content: [{ type: "text", text: `Artifact ${artifactName} not found in build ${buildId}.` }],
};
}

const fileStream = await buildApi.getArtifactContentZip(project, buildId, artifactName);

// If destinationPath is provided, save to disk
if (destinationPath) {
const fullDestinationPath = resolve(destinationPath);

mkdirSync(fullDestinationPath, { recursive: true });
const fileDestinationPath = join(fullDestinationPath, `${artifactName}.zip`);

const writeStream = createWriteStream(fileDestinationPath);
await new Promise<void>((resolve, reject) => {
fileStream.pipe(writeStream);
fileStream.on("end", () => resolve());
fileStream.on("error", (err) => reject(err));
});

return {
content: [{ type: "text", text: `Artifact ${artifactName} downloaded to ${destinationPath}.` }],
};
}

// Otherwise, return binary content as base64
const chunks: Buffer[] = [];
await new Promise<void>((resolve, reject) => {
fileStream.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
fileStream.on("end", () => resolve());
fileStream.on("error", (err) => reject(err));
});

const buffer = Buffer.concat(chunks);
const base64Data = buffer.toString("base64");

return {
content: [
{
type: "resource",
resource: {
uri: `data:application/zip;base64,${base64Data}`,
mimeType: "application/zip",
text: base64Data,
},
},
],
};
}
);
}

export { PIPELINE_TOOLS, configurePipelineTools };
25 changes: 25 additions & 0 deletions test/mocks/pipelines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,28 @@ export const mockUpdateBuildStageResponse = {
name: "Build",
forceRetryAllJobs: false,
};

export const mockArtifact = {
id: 1,
name: "drop",
resource: { type: "Container", data: "123456" },
};

export const mockMultipleArtifacts = [
{
id: 1,
name: "drop",
resource: {
type: "Container",
data: "123456",
},
},
{
id: 2,
name: "logs",
resource: {
type: "Container",
data: "789012",
},
},
];
207 changes: 206 additions & 1 deletion test/src/tools/pipelines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@ import { WebApi } from "azure-devops-node-api";
import { StageUpdateType } from "azure-devops-node-api/interfaces/BuildInterfaces.js";
import { configurePipelineTools } from "../../../src/tools/pipelines";
import { apiVersion } from "../../../src/utils.js";
import { mockUpdateBuildStageResponse } from "../../mocks/pipelines";
import { mockUpdateBuildStageResponse, mockMultipleArtifacts, mockArtifact } from "../../mocks/pipelines";
import { Readable } from "stream";
import { resolve } from "path";
import { mkdirSync, createWriteStream } from "fs";

// Mock fetch globally
global.fetch = jest.fn() as jest.MockedFunction<typeof fetch>;

jest.mock("fs");

type TokenProviderMock = () => Promise<string>;
type ConnectionProviderMock = () => Promise<WebApi>;

Expand Down Expand Up @@ -958,4 +963,204 @@ describe("configurePipelineTools", () => {
await expect(handler(params)).rejects.toThrow("API Error");
});
});

describe("pipelines_list_artifacts", () => {
it("should list artifacts for a given build", async () => {
const mockGetArtifacts = jest.fn().mockResolvedValue(mockMultipleArtifacts);
mockConnection.getBuildApi.mockResolvedValue({ getArtifacts: mockGetArtifacts } as any);

configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider);
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_list_artifacts");
if (!call) throw new Error("pipelines_list_artifacts tool not registered");
const [, , , handler] = call;

const params = { project: "test-project", buildId: 12345 };
const result = await handler(params);

expect(mockGetArtifacts).toHaveBeenCalledWith("test-project", 12345);
expect(result.content[0].text).toContain("drop");
expect(result.content[0].text).toContain("logs");
expect(result.content[0].text).toContain("Container");
});

it("should handle empty artifact list", async () => {
const mockGetArtifacts = jest.fn().mockResolvedValue([]);
mockConnection.getBuildApi.mockResolvedValue({ getArtifacts: mockGetArtifacts } as any);

configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider);
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_list_artifacts");
if (!call) throw new Error("pipelines_list_artifacts tool not registered");
const [, , , handler] = call;

const params = { project: "test-project", buildId: 99999 };

const result = await handler(params);

expect(mockGetArtifacts).toHaveBeenCalledWith("test-project", 99999);
expect(result.content[0].text).toBe("[]");
});

it("should handle errors when listing artifacts", async () => {
const mockGetArtifacts = jest.fn().mockRejectedValue(new Error("Build not found"));
mockConnection.getBuildApi.mockResolvedValue({ getArtifacts: mockGetArtifacts } as any);

configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider);
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_list_artifacts");
if (!call) throw new Error("pipelines_list_artifacts tool not registered");
const [, , , handler] = call;

const params = { project: "test-project", buildId: 12345 };
await expect(handler(params)).rejects.toThrow("Build not found");
});
});

describe("pipelines_download_artifact", () => {
let mockWriteStream: any;
let mockFileStream: Readable;

beforeEach(() => {
mockWriteStream = {
write: jest.fn(),
end: jest.fn(),
on: jest.fn(),
once: jest.fn(),
emit: jest.fn(),
};
(createWriteStream as jest.Mock).mockReturnValue(mockWriteStream);
(mkdirSync as jest.Mock).mockReturnValue(undefined);

// Create a mock readable stream
mockFileStream = new Readable({
read() {
this.push(Buffer.from("fake zip content"));
this.push(null);
},
});
});

it("should download and save an artifact", async () => {
const mockGetArtifact = jest.fn().mockResolvedValue(mockArtifact);
const mockGetArtifactContentZip = jest.fn().mockResolvedValue(mockFileStream);

mockConnection.getBuildApi.mockResolvedValue({
getArtifact: mockGetArtifact,
getArtifactContentZip: mockGetArtifactContentZip,
} as any);

configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider);
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_download_artifact");
if (!call) throw new Error("pipelines_download_artifact tool not registered");
const [, , , handler] = call;

const params = {
project: "test-project",
buildId: 12345,
artifactName: "drop",
destinationPath: "D:\\temp\\artifacts",
};

const result = await handler(params);

expect(mockGetArtifact).toHaveBeenCalledWith("test-project", 12345, "drop");
expect(mockGetArtifactContentZip).toHaveBeenCalledWith("test-project", 12345, "drop");
expect(mkdirSync).toHaveBeenCalledWith(resolve("D:\\temp\\artifacts"), { recursive: true });
expect(createWriteStream).toHaveBeenCalledWith(expect.stringContaining("drop.zip"));
expect(result.content[0].text).toContain("Artifact drop downloaded");
});

it("should handle artifact not found", async () => {
const mockGetArtifact = jest.fn().mockResolvedValue(null);

mockConnection.getBuildApi.mockResolvedValue({
getArtifact: mockGetArtifact,
} as any);

configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider);
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_download_artifact");
if (!call) throw new Error("pipelines_download_artifact tool not registered");
const [, , , handler] = call;

const params = {
project: "test-project",
buildId: 12345,
artifactName: "drop",
destinationPath: "D:\\temp\\artifacts",
};

const result = await handler(params);

expect(result.content[0].text).toContain("Artifact drop not found");
});

it("should handle download errors correctly", async () => {
const mockGetArtifact = jest.fn().mockResolvedValue(mockArtifact);
const mockGetArtifactContentZip = jest.fn().mockRejectedValue(new Error("Network error"));

mockConnection.getBuildApi.mockResolvedValue({
getArtifact: mockGetArtifact,
getArtifactContentZip: mockGetArtifactContentZip,
} as any);

configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider);
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_download_artifact");
if (!call) throw new Error("pipelines_download_artifact tool not registered");
const [, , , handler] = call;

const params = {
project: "test-project",
buildId: 12345,
artifactName: "drop",
destinationPath: "D:\\temp\\artifacts",
};

await expect(handler(params)).rejects.toThrow("Network error");
});

it("should return artifact as base64 binary when destinationPath is not provided", async () => {
const mockGetArtifact = jest.fn().mockResolvedValue(mockArtifact);

// Create a mock readable stream with test content
const testContent = Buffer.from("fake zip content for binary test");
const mockFileStream = new Readable({
read() {
this.push(testContent);
this.push(null);
},
});

const mockGetArtifactContentZip = jest.fn().mockResolvedValue(mockFileStream);

mockConnection.getBuildApi.mockResolvedValue({
getArtifact: mockGetArtifact,
getArtifactContentZip: mockGetArtifactContentZip,
} as any);

configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider);
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_download_artifact");
if (!call) throw new Error("pipelines_download_artifact tool not registered");
const [, , , handler] = call;

const params = {
project: "test-project",
buildId: 12345,
artifactName: "drop",
// No destinationPath provided - should return binary
};

const result = await handler(params);

expect(mockGetArtifact).toHaveBeenCalledWith("test-project", 12345, "drop");
expect(mockGetArtifactContentZip).toHaveBeenCalledWith("test-project", 12345, "drop");

// Verify the result contains base64 encoded binary content
expect(result.content[0].type).toBe("resource");
expect(result.content[0].resource.mimeType).toBe("application/zip");
expect(result.content[0].resource.uri).toContain("data:application/zip;base64,");

// Verify the base64 content matches the original
const expectedBase64 = testContent.toString("base64");
expect(result.content[0].resource.text).toBe(expectedBase64);
expect(result.content[0].resource.uri).toContain(expectedBase64);
});
});
});
Loading