package buildkite
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/url"
"strings"
"github.com/buildkite/buildkite-mcp-server/pkg/tokens"
"github.com/buildkite/buildkite-mcp-server/pkg/trace"
"github.com/buildkite/go-buildkite/v4"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"go.opentelemetry.io/otel/attribute"
)
type ArtifactsClient interface {
ListByBuild(ctx context.Context, org, pipelineSlug, buildNumber string, opts *buildkite.ArtifactListOptions) ([]buildkite.Artifact, *buildkite.Response, error)
ListByJob(ctx context.Context, org, pipelineSlug, buildNumber string, jobID string, opts *buildkite.ArtifactListOptions) ([]buildkite.Artifact, *buildkite.Response, error)
DownloadArtifactByURL(ctx context.Context, url string, writer io.Writer) (*buildkite.Response, error)
}
// BuildkiteClientAdapter adapts the buildkite.Client to work with our interfaces
type BuildkiteClientAdapter struct {
*buildkite.Client
}
// ListByBuild implements ArtifactsClient
func (a *BuildkiteClientAdapter) ListByBuild(ctx context.Context, org, pipelineSlug, buildNumber string, opts *buildkite.ArtifactListOptions) ([]buildkite.Artifact, *buildkite.Response, error) {
return a.Artifacts.ListByBuild(ctx, org, pipelineSlug, buildNumber, opts)
}
// ListByJob implements ArtifactsClient
func (a *BuildkiteClientAdapter) ListByJob(ctx context.Context, org, pipelineSlug, buildNumber string, jobID string, opts *buildkite.ArtifactListOptions) ([]buildkite.Artifact, *buildkite.Response, error) {
return a.Artifacts.ListByJob(ctx, org, pipelineSlug, buildNumber, jobID, opts)
}
// DownloadArtifactByURL implements ArtifactsClient with URL rewriting support
func (a *BuildkiteClientAdapter) DownloadArtifactByURL(ctx context.Context, url string, writer io.Writer) (*buildkite.Response, error) {
// Rewrite URL if it's using the default Buildkite API URL and we have a custom base URL
rewrittenURL := a.rewriteArtifactURL(url)
return a.Artifacts.DownloadArtifactByURL(ctx, rewrittenURL, writer)
}
// rewriteArtifactURL rewrites artifact URLs to use the configured base URL
func (a *BuildkiteClientAdapter) rewriteArtifactURL(inputURL string) string {
// Parse the input URL
parsedURL, err := url.Parse(inputURL)
if err != nil {
// If we can't parse the URL, return it as-is
return inputURL
}
// Get the configured base URL from the client
baseURL := a.BaseURL
if baseURL == nil || baseURL.String() == "" {
return inputURL
}
// Only rewrite if the base URL is different from the input URL's host and scheme
// and the base URL is non-empty
if baseURL.Host != parsedURL.Host || baseURL.Scheme != parsedURL.Scheme {
// Replace the host and scheme with the configured base URL
parsedURL.Scheme = baseURL.Scheme
parsedURL.Host = baseURL.Host
// If the base URL has a path prefix, prepend it to the existing path
if baseURL.Path != "" && baseURL.Path != "/" {
// Remove trailing slash from base path if present
basePath := strings.TrimSuffix(baseURL.Path, "/")
parsedURL.Path = basePath + parsedURL.Path
}
}
return parsedURL.String()
}
func ListArtifactsForBuild(client ArtifactsClient) (tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) {
return mcp.NewTool("list_artifacts_for_build",
mcp.WithDescription("List all artifacts for a build across all jobs, including file details, paths, sizes, MIME types, and download URLs"),
mcp.WithString("org_slug",
mcp.Required(),
),
mcp.WithString("pipeline_slug",
mcp.Required(),
),
mcp.WithString("build_number",
mcp.Required(),
),
withPagination(),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: "Build Artifact List",
ReadOnlyHint: mcp.ToBoolPtr(true),
}),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ctx, span := trace.Start(ctx, "buildkite.ListArtifactsForBuild")
defer span.End()
orgSlug, err := request.RequireString("org_slug")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
pipelineSlug, err := request.RequireString("pipeline_slug")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
buildNumber, err := request.RequireString("build_number")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
paginationParams, err := optionalPaginationParams(request)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
span.SetAttributes(
attribute.String("org_slug", orgSlug),
attribute.String("pipeline_slug", pipelineSlug),
attribute.String("build_number", buildNumber),
attribute.Int("page", paginationParams.Page),
attribute.Int("per_page", paginationParams.PerPage),
)
artifacts, resp, err := client.ListByBuild(ctx, orgSlug, pipelineSlug, buildNumber, &buildkite.ArtifactListOptions{
ListOptions: paginationParams,
})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
result := PaginatedResult[buildkite.Artifact]{
Items: artifacts,
Headers: map[string]string{
"Link": resp.Header.Get("Link"),
},
}
r, err := json.Marshal(result)
if err != nil {
return nil, fmt.Errorf("failed to marshal artifacts: %w", err)
}
span.SetAttributes(
attribute.Int("item_count", len(artifacts)),
attribute.Int("estimated_tokens", tokens.EstimateTokens(string(r))),
)
return mcp.NewToolResultText(string(r)), nil
}, []string{"read_artifacts"}
}
func ListArtifactsForJob(client ArtifactsClient) (tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) {
return mcp.NewTool("list_artifacts_for_job",
mcp.WithDescription("List all artifacts for an individual job, including file details, paths, sizes, MIME types, and download URLs"),
mcp.WithString("org_slug",
mcp.Required(),
),
mcp.WithString("pipeline_slug",
mcp.Required(),
),
mcp.WithString("build_number",
mcp.Required(),
),
mcp.WithString("job_id",
mcp.Required(),
),
withPagination(),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: "Job Artifact List",
ReadOnlyHint: mcp.ToBoolPtr(true),
}),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ctx, span := trace.Start(ctx, "buildkite.ListArtifactsForJob")
defer span.End()
orgSlug, err := request.RequireString("org_slug")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
pipelineSlug, err := request.RequireString("pipeline_slug")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
buildNumber, err := request.RequireString("build_number")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
jobID, err := request.RequireString("job_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
paginationParams, err := optionalPaginationParams(request)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
span.SetAttributes(
attribute.String("org_slug", orgSlug),
attribute.String("pipeline_slug", pipelineSlug),
attribute.String("build_number", buildNumber),
attribute.String("job_id", jobID),
attribute.Int("page", paginationParams.Page),
attribute.Int("per_page", paginationParams.PerPage),
)
artifacts, resp, err := client.ListByJob(ctx, orgSlug, pipelineSlug, buildNumber, jobID, &buildkite.ArtifactListOptions{
ListOptions: paginationParams,
})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
result := PaginatedResult[buildkite.Artifact]{
Items: artifacts,
Headers: map[string]string{
"Link": resp.Header.Get("Link"),
},
}
r, err := json.Marshal(result)
if err != nil {
return nil, fmt.Errorf("failed to marshal artifacts: %w", err)
}
span.SetAttributes(
attribute.Int("item_count", len(artifacts)),
attribute.Int("estimated_tokens", tokens.EstimateTokens(string(r))),
)
return mcp.NewToolResultText(string(r)), nil
}, []string{"read_artifacts"}
}
func GetArtifact(client ArtifactsClient) (tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) {
return mcp.NewTool("get_artifact",
mcp.WithDescription("Get detailed information about a specific artifact including its metadata, file size, SHA-1 hash, and download URL"),
mcp.WithString("url",
mcp.Required(),
),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: "Get Artifact",
ReadOnlyHint: mcp.ToBoolPtr(true),
}),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ctx, span := trace.Start(ctx, "buildkite.GetArtifact")
defer span.End()
artifactURL, err := request.RequireString("url")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
// Validate the URL format
if _, err = url.Parse(artifactURL); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid URL format: %s", err.Error())), nil
}
span.SetAttributes(attribute.String("url", artifactURL))
// Use a buffer to capture the artifact data instead of writing directly to stdout
var buffer bytes.Buffer
resp, err := client.DownloadArtifactByURL(ctx, artifactURL, &buffer)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("response failed with error %s", err.Error())), nil
}
// Create a response with the artifact data encoded safely for JSON
result := map[string]any{
"status": resp.Status,
"statusCode": resp.StatusCode,
"data": base64.StdEncoding.EncodeToString(buffer.Bytes()),
"encoding": "base64",
}
return mcpTextResult(span, &result)
}, []string{"read_artifacts"}
}