Skip to content
Merged
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
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,20 @@ docker run --pull=always -q -it --rm -e BUILDKITE_API_TOKEN=bkua_xxxxx buildkite

## 🔑 API Token Scopes

### Full functionality
### All READ and WRITE functionality

👉 **Quick add:** [Create token with Full functionality](https://buildkite.com/user/api-access-tokens/new?scopes[]=read_clusters&scopes[]=read_pipelines&scopes[]=read_builds&scopes[]=read_build_logs&scopes[]=read_user&scopes[]=read_organizations&scopes[]=read_artifacts&scopes[]=read_suites)
👉 **Quick add:** [Create token with READ and WRITE functionality](https://buildkite.com/user/api-access-tokens/new?scopes[]=read_clusters&scopes[]=read_pipelines&scopes[]=read_builds&scopes[]=read_build_logs&scopes[]=read_user&scopes[]=read_organizations&scopes[]=read_artifacts&scopes[]=read_suites&scopes[]=write_builds&scopes[]=write_pipelines)

| Scope | Purpose |
|-------|---------|
| `write_pipelines` | Create and update pipelines |
| `write_builds` | Create builds, unblock jobs, trigger builds |

*Includes all READONLY and Minimum scopes listed below.*

### All READONLY functionality

👉 **Quick add:** [Create token with READONLY functionality](https://buildkite.com/user/api-access-tokens/new?scopes[]=read_clusters&scopes[]=read_pipelines&scopes[]=read_builds&scopes[]=read_build_logs&scopes[]=read_user&scopes[]=read_organizations&scopes[]=read_artifacts&scopes[]=read_suites)

| Scope | Purpose |
|-------|---------|
Expand All @@ -60,6 +71,8 @@ docker run --pull=always -q -it --rm -e BUILDKITE_API_TOKEN=bkua_xxxxx buildkite
| `read_artifacts` | Build artifacts & metadata |
| `read_suites` | Buildkite Test Engine data |

*Includes Minimum scopes listed below.*

### Minimum recommended

👉 **Quick add:** [Create token with Basic functionality](https://buildkite.com/user/api-access-tokens/new?scopes[]=read_builds&scopes[]=read_pipelines&scopes[]=read_user)
Expand All @@ -70,6 +83,8 @@ docker run --pull=always -q -it --rm -e BUILDKITE_API_TOKEN=bkua_xxxxx buildkite
| `read_pipelines` | Pipeline information |
| `read_user` | User identification |

> **Note:** Tools requiring write access, like `unblock_job`, `create_build` and `create_pipeline` require the "All READ and WRITE functionality" token.

---

## 📦 Installation
Expand Down Expand Up @@ -429,6 +444,7 @@ Or you can manually configure:
| `current_user` | Get details about the user account that owns the API token, including name, email, avatar, and account creation date |
| `user_token_organization` | Get the organization associated with the user token used for this request |
| `get_jobs` | Get all jobs for a specific build including their state, timing, commands, and execution details |
| `unblock_job` | Unblock a blocked job in a Buildkite build to allow it to continue execution |
| `list_artifacts` | List all artifacts for a build across all jobs, including file details, paths, sizes, MIME types, and download URLs |
| `get_artifact` | Get detailed information about a specific artifact including its metadata, file size, SHA-1 hash, and download URL |
| `list_annotations` | List all annotations for a build, including their context, style (success/info/warning/error), rendered HTML content, and creation timestamps |
Expand Down
91 changes: 91 additions & 0 deletions pkg/buildkite/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package buildkite
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -19,6 +20,11 @@ import (
"go.opentelemetry.io/otel/attribute"
)

type JobsClient interface {
GetJobLog(ctx context.Context, org string, pipeline string, buildNumber string, jobID string) (buildkite.JobLog, *buildkite.Response, error)
UnblockJob(ctx context.Context, org string, pipeline string, buildNumber string, jobID string, opt *buildkite.JobUnblockOptions) (buildkite.Job, *buildkite.Response, error)
}

// GetJobsArgs struct for typed parameters
type GetJobsArgs struct {
OrgSlug string `json:"org_slug"`
Expand All @@ -38,6 +44,15 @@ type GetJobLogsArgs struct {
JobUUID string `json:"job_uuid"`
}

// UnblockJobArgs struct for typed parameters
type UnblockJobArgs struct {
OrgSlug string `json:"org_slug"`
PipelineSlug string `json:"pipeline_slug"`
BuildNumber string `json:"build_number"`
JobID string `json:"job_id"`
Fields map[string]string `json:"fields,omitempty"`
}

func GetJobs(client BuildsClient) (tool mcp.Tool, handler mcp.TypedToolHandlerFunc[GetJobsArgs]) {
return mcp.NewTool("get_jobs",
mcp.WithDescription("Get all jobs for a specific build including their state, timing, commands, and execution details"),
Expand Down Expand Up @@ -338,3 +353,79 @@ func handleLargeLogFile(ctx context.Context, processedLog string, response JobLo

return mcp.NewToolResultText(string(r)), nil
}

func UnblockJob(client JobsClient) (tool mcp.Tool, handler mcp.TypedToolHandlerFunc[UnblockJobArgs]) {
return mcp.NewTool("unblock_job",
mcp.WithDescription("Unblock a blocked job in a Buildkite build to allow it to continue execution"),
mcp.WithString("org_slug",
mcp.Required(),
),
mcp.WithString("pipeline_slug",
mcp.Required(),
),
mcp.WithString("build_number",
mcp.Required(),
),
mcp.WithString("job_id",
mcp.Required(),
),
mcp.WithObject("fields",
mcp.Description("JSON object containing string values for block step fields"),
),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: "Unblock Job",
ReadOnlyHint: mcp.ToBoolPtr(false),
}),
),
func(ctx context.Context, request mcp.CallToolRequest, args UnblockJobArgs) (*mcp.CallToolResult, error) {
ctx, span := trace.Start(ctx, "buildkite.UnblockJob")
defer span.End()

// Validate required parameters
if args.OrgSlug == "" {
return mcp.NewToolResultError("org_slug parameter is required"), nil
}
if args.PipelineSlug == "" {
return mcp.NewToolResultError("pipeline_slug parameter is required"), nil
}
if args.BuildNumber == "" {
return mcp.NewToolResultError("build_number parameter is required"), nil
}
if args.JobID == "" {
return mcp.NewToolResultError("job_id parameter is required"), nil
}

span.SetAttributes(
attribute.String("org_slug", args.OrgSlug),
attribute.String("pipeline_slug", args.PipelineSlug),
attribute.String("build_number", args.BuildNumber),
attribute.String("job_id", args.JobID),
)

// Prepare unblock options
unblockOptions := buildkite.JobUnblockOptions{}
if len(args.Fields) > 0 {
unblockOptions.Fields = args.Fields
}

// Unblock the job
job, _, err := client.UnblockJob(ctx, args.OrgSlug, args.PipelineSlug, args.BuildNumber, args.JobID, &unblockOptions)
if err != nil {
var errResp *buildkite.ErrorResponse
if errors.As(err, &errResp) {
if errResp.RawBody != nil {
return mcp.NewToolResultError(string(errResp.RawBody)), nil
}
}

return mcp.NewToolResultError(err.Error()), nil
}

r, err := json.Marshal(job)
if err != nil {
return nil, fmt.Errorf("failed to marshal job: %w", err)
}

return mcp.NewToolResultText(string(r)), nil
}
}
174 changes: 174 additions & 0 deletions pkg/buildkite/jobs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package buildkite

import (
"context"
"errors"
"net/http"
"testing"

Expand Down Expand Up @@ -567,3 +568,176 @@ func TestGetJobLogs(t *testing.T) {
assert.NotEmpty(result.Content)
})
}

// MockJobsClient for testing unblock functionality
type MockJobsClient struct {
UnblockJobFunc func(ctx context.Context, org string, pipeline string, buildNumber string, jobID string, opt *buildkite.JobUnblockOptions) (buildkite.Job, *buildkite.Response, error)
GetJobLogFunc func(ctx context.Context, org string, pipeline string, buildNumber string, jobID string) (buildkite.JobLog, *buildkite.Response, error)
}

func (m *MockJobsClient) UnblockJob(ctx context.Context, org string, pipeline string, buildNumber string, jobID string, opt *buildkite.JobUnblockOptions) (buildkite.Job, *buildkite.Response, error) {
if m.UnblockJobFunc != nil {
return m.UnblockJobFunc(ctx, org, pipeline, buildNumber, jobID, opt)
}
return buildkite.Job{}, &buildkite.Response{}, nil
}

func (m *MockJobsClient) GetJobLog(ctx context.Context, org string, pipeline string, buildNumber string, jobID string) (buildkite.JobLog, *buildkite.Response, error) {
if m.GetJobLogFunc != nil {
return m.GetJobLogFunc(ctx, org, pipeline, buildNumber, jobID)
}
return buildkite.JobLog{}, &buildkite.Response{}, nil
}

func TestUnblockJob(t *testing.T) {
ctx := context.Background()

// Test tool definition
t.Run("ToolDefinition", func(t *testing.T) {
tool, _ := UnblockJob(&MockJobsClient{})
assert.Equal(t, "unblock_job", tool.Name)
assert.Contains(t, tool.Description, "Unblock a blocked job")
})

// Test successful unblock
t.Run("SuccessfulUnblock", func(t *testing.T) {
mockJobs := &MockJobsClient{
UnblockJobFunc: func(ctx context.Context, org string, pipeline string, buildNumber string, jobID string, opt *buildkite.JobUnblockOptions) (buildkite.Job, *buildkite.Response, error) {
assert.Equal(t, "test-org", org)
assert.Equal(t, "test-pipeline", pipeline)
assert.Equal(t, "123", buildNumber)
assert.Equal(t, "job-123", jobID)

return buildkite.Job{
ID: jobID,
State: "unblocked",
}, &buildkite.Response{
Response: &http.Response{
StatusCode: 200,
},
}, nil
},
}

_, handler := UnblockJob(mockJobs)

req := createMCPRequest(t, map[string]any{})
args := UnblockJobArgs{
OrgSlug: "test-org",
PipelineSlug: "test-pipeline",
BuildNumber: "123",
JobID: "job-123",
}

result, err := handler(ctx, req, args)
require.NoError(t, err)
assert.NotNil(t, result)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, `"id":"job-123"`)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, `"state":"unblocked"`)
})

// Test with fields
t.Run("UnblockWithFields", func(t *testing.T) {
mockJobs := &MockJobsClient{
UnblockJobFunc: func(ctx context.Context, org string, pipeline string, buildNumber string, jobID string, opt *buildkite.JobUnblockOptions) (buildkite.Job, *buildkite.Response, error) {
// Verify fields were passed correctly
require.NotNil(t, opt)
assert.Equal(t, "v1.0.0", opt.Fields["version"])
assert.Equal(t, "prod", opt.Fields["environment"])

return buildkite.Job{
ID: jobID,
State: "unblocked",
}, &buildkite.Response{
Response: &http.Response{
StatusCode: 200,
},
}, nil
},
}

_, handler := UnblockJob(mockJobs)

req := createMCPRequest(t, map[string]any{})
args := UnblockJobArgs{
OrgSlug: "test-org",
PipelineSlug: "test-pipeline",
BuildNumber: "123",
JobID: "job-123",
Fields: map[string]string{"version": "v1.0.0", "environment": "prod"},
}

result, err := handler(ctx, req, args)
require.NoError(t, err)
assert.NotNil(t, result)
})

// Test client error
t.Run("ClientError", func(t *testing.T) {
mockJobs := &MockJobsClient{
UnblockJobFunc: func(ctx context.Context, org string, pipeline string, buildNumber string, jobID string, opt *buildkite.JobUnblockOptions) (buildkite.Job, *buildkite.Response, error) {
return buildkite.Job{}, nil, errors.New("API connection failed")
},
}

_, handler := UnblockJob(mockJobs)

req := createMCPRequest(t, map[string]any{})
args := UnblockJobArgs{
OrgSlug: "test-org",
PipelineSlug: "test-pipeline",
BuildNumber: "123",
JobID: "job-123",
}

result, err := handler(ctx, req, args)
require.NoError(t, err)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "API connection failed")
})

// Test missing parameters
t.Run("MissingParameters", func(t *testing.T) {
_, handler := UnblockJob(&MockJobsClient{})

// Test missing org parameter
req := createMCPRequest(t, map[string]any{})
args := UnblockJobArgs{
PipelineSlug: "test-pipeline",
BuildNumber: "123",
JobID: "job-123",
}
result, err := handler(ctx, req, args)
require.NoError(t, err)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "org_slug parameter is required")

// Test missing pipeline_slug parameter
args = UnblockJobArgs{
OrgSlug: "test-org",
BuildNumber: "123",
JobID: "job-123",
}
result, err = handler(ctx, req, args)
require.NoError(t, err)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "pipeline_slug parameter is required")

// Test missing build_number parameter
args = UnblockJobArgs{
OrgSlug: "test-org",
PipelineSlug: "test-pipeline",
JobID: "job-123",
}
result, err = handler(ctx, req, args)
require.NoError(t, err)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "build_number parameter is required")

// Test missing job_id parameter
args = UnblockJobArgs{
OrgSlug: "test-org",
PipelineSlug: "test-pipeline",
BuildNumber: "123",
}
result, err = handler(ctx, req, args)
require.NoError(t, err)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "job_id parameter is required")
})
}
3 changes: 3 additions & 0 deletions pkg/server/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ func BuildkiteTools(client *gobuildkite.Client, buildkiteLogsClient *buildkitelo
tools = addTool(
fromTypeTool(buildkite.GetJobs(client.Builds)),
)
tools = addTool(
fromTypeTool(buildkite.UnblockJob(client.Jobs)),
)

// Artifacts tools
tools = addTool(buildkite.ListArtifacts(clientAdapter))
Expand Down