├── CLAUDE.md
├── mise.toml
├── docs
└── images
│ └── get_pipeline.png
├── .gitignore
├── .buildkite
├── Dockerfile.build
├── docker-compose.yaml
├── release.sh
├── pipeline.release.yml
└── pipeline.yml
├── .github
└── dependabot.yml
├── pkg
├── trace
│ ├── trace_test.go
│ └── trace.go
├── buildkite
│ ├── resources.go
│ ├── access_token.go
│ ├── user.go
│ ├── user_test.go
│ ├── organizations.go
│ ├── access_token_test.go
│ ├── tests.go
│ ├── tests_test.go
│ ├── annotations_test.go
│ ├── clusters_test.go
│ ├── annotations.go
│ ├── cluster_queue_test.go
│ ├── test_executions.go
│ ├── organizations_test.go
│ ├── jobs.go
│ ├── clusters.go
│ ├── buildkite.go
│ ├── cluster_queue.go
│ ├── test_runs.go
│ ├── jobs_test.go
│ ├── buildkite_test.go
│ ├── resources
│ │ └── debug-logs-guide.md
│ ├── joblogs_test.go
│ ├── artifacts.go
│ ├── test_runs_test.go
│ ├── pipelines_test.go
│ └── test_executions_test.go
├── tokens
│ ├── tokens.go
│ └── tokens_test.go
├── server
│ └── mcp.go
└── toolsets
│ └── toolsets.go
├── Dockerfile.local
├── internal
└── commands
│ ├── tools.go
│ ├── headers.go
│ ├── headers_test.go
│ ├── stdio.go
│ ├── command.go
│ └── http.go
├── .golangci.yml
├── LICENSE
├── README.md
├── AGENT.md
├── Makefile
├── cmd
├── update-docs
│ └── main.go
└── buildkite-mcp-server
│ └── main.go
├── server.json
├── .goreleaser.yaml
├── DEVELOPMENT.md
├── go.mod
└── CODE_OF_CONDUCT.md
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | AGENT.md
--------------------------------------------------------------------------------
/mise.toml:
--------------------------------------------------------------------------------
1 | [tools]
2 | go = "1.24.5"
3 | node = "22"
4 |
--------------------------------------------------------------------------------
/docs/images/get_pipeline.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/buildkite/buildkite-mcp-server/HEAD/docs/images/get_pipeline.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist/
2 | .envrc*
3 | .zed
4 | .vscode
5 | vendor/
6 | *.zip
7 | .bk.yaml
8 | /buildkite-mcp-server
9 | coverage.out
10 | /specs
11 |
12 | # debugger binaries
13 | __debug_bin*
14 | /*.json
15 |
--------------------------------------------------------------------------------
/.buildkite/Dockerfile.build:
--------------------------------------------------------------------------------
1 | FROM golang:1.25.5@sha256:36b4f45d2874905b9e8573b783292629bcb346d0a70d8d7150b6df545234818f
2 |
3 | COPY --from=goreleaser/goreleaser-pro:v2.9.0@sha256:adf70d3f53233855f6091c58c2e3f182fd41311fe322cbf3284994bb6991a53d /usr/bin/goreleaser /usr/local/bin/goreleaser
4 | COPY --from=ghcr.io/ko-build/ko:20b15e67194215721faba356b857fcf5d621dfaa /ko-app/ko /usr/local/bin/ko
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: gomod
4 | directory: /
5 | schedule:
6 | interval: weekly
7 | open-pull-requests-limit: 10
8 | groups:
9 | otel:
10 | patterns:
11 | - go.opentelemetry.io/*
12 | golang-x:
13 | patterns:
14 | - golang.org/x/*
15 | - package-ecosystem: docker
16 | directory: .buildkite
17 | schedule:
18 | interval: weekly
19 | open-pull-requests-limit: 10
20 |
--------------------------------------------------------------------------------
/pkg/trace/trace_test.go:
--------------------------------------------------------------------------------
1 | package trace
2 |
3 | import (
4 | "context"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | func TestNewProvider(t *testing.T) {
11 | assert := require.New(t)
12 |
13 | provider, err := NewProvider(context.Background(), "http/protobuf", "test", "1.2.3")
14 | assert.NoError(err)
15 |
16 | assert.NotNil(provider)
17 |
18 | provider, err = NewProvider(context.Background(), "grpc", "test", "1.2.3")
19 | assert.NoError(err)
20 |
21 | assert.NotNil(provider)
22 |
23 | _, err = NewProvider(context.Background(), "", "test", "1.2.3")
24 | assert.NoError(err)
25 | }
26 |
--------------------------------------------------------------------------------
/pkg/buildkite/resources.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 | "embed"
6 |
7 | "github.com/mark3labs/mcp-go/mcp"
8 | )
9 |
10 | //go:embed resources/*.md
11 | var resourcesFS embed.FS
12 |
13 | func HandleDebugLogsGuideResource(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
14 | content, err := resourcesFS.ReadFile("resources/debug-logs-guide.md")
15 | if err != nil {
16 | return nil, err
17 | }
18 |
19 | return []mcp.ResourceContents{
20 | &mcp.TextResourceContents{
21 | URI: request.Params.URI,
22 | MIMEType: "text/markdown",
23 | Text: string(content),
24 | },
25 | }, nil
26 | }
27 |
--------------------------------------------------------------------------------
/pkg/tokens/tokens.go:
--------------------------------------------------------------------------------
1 | package tokens
2 |
3 | import (
4 | "strings"
5 | )
6 |
7 | // EstimateTokens returns an estimate of the number of tokens in the given text.
8 | func EstimateTokens(text string) int {
9 | words := strings.Fields(text)
10 | tokenCount := 0
11 |
12 | for _, word := range words {
13 | // Simple heuristic: longer words typically split into more tokens
14 | wordLen := len([]rune(word))
15 | switch {
16 | case wordLen <= 4:
17 | tokenCount += 1
18 | case wordLen <= 8:
19 | tokenCount += 2
20 | default:
21 | // For longer words, assume 1 token per 4 characters, rounded up
22 | tokenCount += (wordLen + 3) / 4
23 | }
24 | }
25 |
26 | return tokenCount
27 | }
28 |
--------------------------------------------------------------------------------
/Dockerfile.local:
--------------------------------------------------------------------------------
1 | # Build stage
2 | FROM cgr.dev/chainguard/go:latest AS builder
3 |
4 | WORKDIR /app
5 |
6 | # Copy go.mod and go.sum first to leverage Docker cache
7 | COPY go.mod go.sum ./
8 | RUN go mod download
9 |
10 | # Copy the rest of the source code
11 | COPY . .
12 |
13 | # Build the binary
14 | RUN CGO_ENABLED=0 go build -o buildkite-mcp-server ./cmd/buildkite-mcp-server/main.go
15 |
16 | # Final stage
17 | FROM cgr.dev/chainguard/static:latest
18 |
19 | WORKDIR /app
20 |
21 | # Copy the binary from the builder stage
22 | COPY --from=builder /app/buildkite-mcp-server /app/buildkite-mcp-server
23 |
24 | # Set the entrypoint to run the server in stdio mode
25 | ENTRYPOINT ["/app/buildkite-mcp-server", "stdio"]
26 |
--------------------------------------------------------------------------------
/internal/commands/tools.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 |
9 | "github.com/buildkite/buildkite-mcp-server/pkg/server"
10 | gobuildkite "github.com/buildkite/go-buildkite/v4"
11 | )
12 |
13 | type ToolsCmd struct{}
14 |
15 | func (c *ToolsCmd) Run(ctx context.Context, globals *Globals) error {
16 | client := &gobuildkite.Client{}
17 |
18 | // Collect all tools (pass nil for ParquetClient since this is just for listing)
19 | tools := server.BuildkiteTools(client, nil, server.WithToolsets("all"))
20 |
21 | for _, tool := range tools {
22 |
23 | buf := new(bytes.Buffer)
24 |
25 | err := json.NewEncoder(buf).Encode(&tool.Tool)
26 | if err != nil {
27 | return err
28 | }
29 |
30 | fmt.Print(buf.String())
31 |
32 | }
33 |
34 | return nil
35 | }
36 |
--------------------------------------------------------------------------------
/internal/commands/headers.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/rs/zerolog/log"
7 | )
8 |
9 | // ParseHeaders takes a slice of header strings in the format "Key: Value"
10 | // and returns a map of headers. This is used to parse additional HTTP headers
11 | // that can be sent with every request to the Buildkite API.
12 | func ParseHeaders(headerStrings []string) map[string]string {
13 | headers := make(map[string]string)
14 | for _, h := range headerStrings {
15 | parts := strings.SplitN(h, ":", 2)
16 | if len(parts) == 2 {
17 | key := strings.TrimSpace(parts[0])
18 | value := strings.TrimSpace(parts[1])
19 | headers[key] = value
20 | log.Debug().Str("key", key).Str("value", value).Msg("parsed header")
21 | } else {
22 | log.Warn().Str("header", h).Msg("invalid header format, expected 'Key: Value'")
23 | }
24 | }
25 | return headers
26 | }
27 |
--------------------------------------------------------------------------------
/.buildkite/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: "3.5"
2 |
3 | services:
4 | golangci-lint:
5 | image: golangci/golangci-lint:v2.0.2
6 | working_dir: /app
7 | volumes:
8 | - ..:/app:cached
9 | - ~/gocache:/gocache
10 | - ~/gomodcache:/gomodcache
11 | environment:
12 | - GOCACHE=/gocache
13 | - GOMODCACHE=/gomodcache
14 | goreleaser:
15 | build:
16 | context: .
17 | dockerfile: Dockerfile.build
18 | environment:
19 | - BUILDKITE_AGENT_JOB_API_SOCKET
20 | - BUILDKITE_AGENT_JOB_API_TOKEN
21 | - GOCACHE=/gocache
22 | - GOMODCACHE=/gomodcache
23 | working_dir: /go/src/github.com/buildkite/buildkite-mcp-server
24 | volumes:
25 | - ..:/go/src/github.com/buildkite/buildkite-mcp-server:cached
26 | - ~/gocache:/gocache
27 | - ~/gomodcache:/gomodcache
28 | - ${BUILDKITE_AGENT_JOB_API_SOCKET}:${BUILDKITE_AGENT_JOB_API_SOCKET}
29 | - /var/run/docker.sock:/var/run/docker.sock
30 |
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | version: "2"
2 | linters:
3 | enable:
4 | - errcheck
5 | - govet
6 | - ineffassign
7 | - staticcheck
8 | - unused
9 | - gosec
10 | - gocritic
11 | - testifylint
12 | exclusions:
13 | generated: lax
14 | presets:
15 | - comments
16 | - common-false-positives
17 | - legacy
18 | - std-error-handling
19 | paths:
20 | - third_party$
21 | - builtin$
22 | - examples$
23 | formatters:
24 | enable:
25 | - gci
26 | - gofmt
27 | settings:
28 | gci:
29 | sections:
30 | - standard
31 | - default
32 | gofmt:
33 | rewrite-rules:
34 | - pattern: "interface{}"
35 | replacement: "any"
36 | - pattern: "a[b:len(a)]"
37 | replacement: "a[b:]"
38 | testifylint:
39 | disable-all: true
40 | enable:
41 | - require-error
42 | - useless-assert
43 | - len
44 | exclusions:
45 | generated: lax
46 | paths:
47 | - third_party$
48 | - builtin$
49 | - examples$
50 |
51 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Buildkite
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # buildkite-mcp-server
2 |
3 | [](https://buildkite.com/buildkite/buildkite-mcp-server)
4 |
5 | > **[Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) server exposing Buildkite data (pipelines, builds, jobs, tests) to AI tooling and editors.**
6 |
7 | Full documentation is available at [buildkite.com/docs/apis/mcp-server](https://buildkite.com/docs/apis/mcp-server).
8 |
9 | ---
10 |
11 | ## Library Usage
12 |
13 | The exported Go API of this module should be considered unstable, and subject to breaking changes as we evolve this project.
14 |
15 | ---
16 |
17 | ## Security
18 |
19 | To ensure the MCP server is run in a secure environment, we recommend running it in a container.
20 |
21 | This image is built from [cgr.dev/chainguard/static](https://images.chainguard.dev/directory/image/static/versions) and runs as an unprivileged user.
22 |
23 | ---
24 |
25 | ## Contributing
26 |
27 | Development guidelines are in [`DEVELOPMENT.md`](DEVELOPMENT.md).
28 |
29 | ---
30 |
31 | ## License
32 |
33 | MIT © Buildkite
34 |
35 | SPDX-License-Identifier: MIT
36 |
--------------------------------------------------------------------------------
/pkg/buildkite/access_token.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/buildkite/buildkite-mcp-server/pkg/trace"
7 | "github.com/buildkite/go-buildkite/v4"
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/mark3labs/mcp-go/server"
10 | )
11 |
12 | type AccessTokenClient interface {
13 | Get(ctx context.Context) (buildkite.AccessToken, *buildkite.Response, error)
14 | }
15 |
16 | func AccessToken(client AccessTokenClient) (tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) {
17 | return mcp.NewTool("access_token",
18 | mcp.WithDescription("Get information about the current API access token including its scopes and UUID"),
19 | mcp.WithToolAnnotation(mcp.ToolAnnotation{
20 | Title: "Get Access Token",
21 | ReadOnlyHint: mcp.ToBoolPtr(true),
22 | }),
23 | ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
24 | ctx, span := trace.Start(ctx, "buildkite.AccessToken")
25 | defer span.End()
26 |
27 | token, _, err := client.Get(ctx)
28 | if err != nil {
29 | return mcp.NewToolResultError(err.Error()), nil
30 | }
31 |
32 | return mcpTextResult(span, &token)
33 | }, []string{"read_user"}
34 | }
35 |
--------------------------------------------------------------------------------
/pkg/buildkite/user.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/buildkite/buildkite-mcp-server/pkg/trace"
7 | "github.com/buildkite/go-buildkite/v4"
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/mark3labs/mcp-go/server"
10 | )
11 |
12 | type UserClient interface {
13 | CurrentUser(ctx context.Context) (buildkite.User, *buildkite.Response, error)
14 | }
15 |
16 | func CurrentUser(client UserClient) (tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) {
17 | tool = mcp.NewTool("current_user",
18 | mcp.WithDescription("Get details about the user account that owns the API token, including name, email, avatar, and account creation date"),
19 | mcp.WithToolAnnotation(mcp.ToolAnnotation{
20 | Title: "Get Current User",
21 | ReadOnlyHint: mcp.ToBoolPtr(true),
22 | }),
23 | )
24 | handler = func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
25 | ctx, span := trace.Start(ctx, "buildkite.CurrentUser")
26 | defer span.End()
27 |
28 | user, _, err := client.CurrentUser(ctx)
29 | if err != nil {
30 | return mcp.NewToolResultError(err.Error()), nil
31 | }
32 |
33 | return mcpTextResult(span, &user)
34 | }
35 | scopes = []string{"read_user"}
36 | return
37 | }
38 |
--------------------------------------------------------------------------------
/internal/commands/headers_test.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestParseHeaders(t *testing.T) {
8 | tests := []struct {
9 | input []string
10 | want map[string]string
11 | }{
12 | {[]string{"Authorization: Bearer token"}, map[string]string{"Authorization": "Bearer token"}},
13 | {[]string{"Authorization: Bearer to.ke.n"}, map[string]string{"Authorization": "Bearer to.ke.n"}},
14 | {[]string{"Key:Value"}, map[string]string{"Key": "Value"}},
15 | {[]string{"Key: Value with spaces"}, map[string]string{"Key": "Value with spaces"}},
16 | {[]string{"NoColonHere"}, map[string]string{}},
17 | {[]string{"JustKey:"}, map[string]string{"JustKey": ""}},
18 | {[]string{":JustValue"}, map[string]string{"": "JustValue"}},
19 | {[]string{"A:1", "B:2"}, map[string]string{"A": "1", "B": "2"}},
20 | {[]string{"A:1", "NoColon", "B:2"}, map[string]string{"A": "1", "B": "2"}},
21 | }
22 |
23 | for _, tt := range tests {
24 | got := ParseHeaders(tt.input)
25 | if len(got) != len(tt.want) {
26 | t.Errorf("parseHeaders(%v) = %v, want %v", tt.input, got, tt.want)
27 | continue
28 | }
29 | for k, v := range tt.want {
30 | if got[k] != v {
31 | t.Errorf("parseHeaders(%v)[%q] = %q, want %q", tt.input, k, got[k], v)
32 | }
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/pkg/tokens/tokens_test.go:
--------------------------------------------------------------------------------
1 | package tokens
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestEstimateTokens(t *testing.T) {
8 | tests := []struct {
9 | name string
10 | input string
11 | expected int
12 | }{
13 | {
14 | name: "empty string",
15 | input: "",
16 | expected: 0,
17 | },
18 | {
19 | name: "single short word",
20 | input: "test",
21 | expected: 1,
22 | },
23 | {
24 | name: "multiple short words",
25 | input: "this is a test",
26 | expected: 4,
27 | },
28 | {
29 | name: "medium length words",
30 | input: "medium length",
31 | expected: 4, // "medium" (6 chars) = 2, "length" (6 chars) = 2
32 | },
33 | {
34 | name: "long words",
35 | input: "internationalization standardization",
36 | expected: 9, // "internationalization" (20 chars) = 5, "standardization" (14 chars) = 4
37 | },
38 | {
39 | name: "mixed length words",
40 | input: "this is a complicated test with internationalization",
41 | expected: 13, // 1+1+1+3+1+1+5 = 13
42 | },
43 | {
44 | name: "multiple spaces",
45 | input: "this is a test",
46 | expected: 4,
47 | },
48 | }
49 |
50 | for _, tt := range tests {
51 | t.Run(tt.name, func(t *testing.T) {
52 | result := EstimateTokens(tt.input)
53 | if result != tt.expected {
54 | t.Errorf("EstimateTokens(%q) = %d, expected %d", tt.input, result, tt.expected)
55 | }
56 | })
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/internal/commands/stdio.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/buildkite/buildkite-mcp-server/pkg/server"
7 | "github.com/buildkite/buildkite-mcp-server/pkg/toolsets"
8 | mcpserver "github.com/mark3labs/mcp-go/server"
9 | "github.com/rs/zerolog/log"
10 | )
11 |
12 | type StdioCmd struct {
13 | EnabledToolsets []string `help:"Comma-separated list of toolsets to enable (e.g., 'pipelines,builds,clusters'). Use 'all' to enable all toolsets." default:"all" env:"BUILDKITE_TOOLSETS"`
14 | ReadOnly bool `help:"Enable read-only mode, which filters out write operations from all toolsets." default:"false" env:"BUILDKITE_READ_ONLY"`
15 | }
16 |
17 | func (c *StdioCmd) Run(ctx context.Context, globals *Globals) error {
18 | // Validate the enabled toolsets
19 | if err := toolsets.ValidateToolsets(c.EnabledToolsets); err != nil {
20 | return err
21 | }
22 |
23 | s := server.NewMCPServer(globals.Version, globals.Client, globals.BuildkiteLogsClient,
24 | server.WithReadOnly(c.ReadOnly), server.WithToolsets(c.EnabledToolsets...))
25 |
26 | return mcpserver.ServeStdio(s,
27 | mcpserver.WithStdioContextFunc(
28 | setupContext(globals),
29 | ),
30 | )
31 | }
32 |
33 | func setupContext(globals *Globals) mcpserver.StdioContextFunc {
34 | return func(ctx context.Context) context.Context {
35 | log.Info().Msg("Starting MCP server over stdio")
36 |
37 | // add the logger to the context
38 | return log.Logger.WithContext(ctx)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/.buildkite/release.sh:
--------------------------------------------------------------------------------
1 | #!/bin/env bash
2 |
3 | #
4 | # This script is used to build a release of the CLI and publish it to multiple registries on Buildkite
5 | #
6 |
7 | # NOTE: do not exit on non-zero returns codes
8 | set -uo pipefail
9 |
10 | export GORELEASER_KEY=""
11 | GORELEASER_KEY=$(buildkite-agent secret get goreleaser_key)
12 |
13 | # check if DOCKERHUB_USER and DOCKERHUB_PASSWORD are set if not skip docker login
14 | if [[ -z "${DOCKERHUB_USER:-}" || -z "${DOCKERHUB_PASSWORD:-}" ]]; then
15 | echo "Skipping Docker login as DOCKERHUB_USER or DOCKERHUB_PASSWORD is not set"
16 | else
17 | echo "--- :key: :docker: Login to Docker hub using ko"
18 | echo "${DOCKERHUB_PASSWORD}" | ko login index.docker.io --username "${DOCKERHUB_USER}" --password-stdin
19 | if [[ $? -ne 0 ]]; then
20 | echo "Docker login failed"
21 | exit 1
22 | fi
23 | fi
24 |
25 | # check if GITHUB_USER is set
26 | if [[ -z "${GITHUB_USER:-}" ]]; then
27 | echo "Skipping GHCR login as GITHUB_USER is not set"
28 | else
29 | echo "--- :key: :github: Login to GHCR using ko"
30 | echo "$GITHUB_TOKEN" | ko login ghcr.io --username "$GITHUB_USER" --password-stdin
31 | if [[ $? -ne 0 ]]; then
32 | echo "GitHub login failed"
33 | exit 1
34 | fi
35 | fi
36 |
37 | echo "--- :goreleaser: Building release with GoReleaser"
38 |
39 | if [[ $? -ne 0 ]]; then
40 | echo "Failed to retrieve GoReleaser Pro key"
41 | exit 1
42 | fi
43 |
44 | if ! goreleaser "$@"; then
45 | echo "Failed to build a release"
46 | exit 1
47 | fi
--------------------------------------------------------------------------------
/pkg/buildkite/user_test.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "testing"
7 |
8 | "github.com/buildkite/go-buildkite/v4"
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | type MockUserClient struct {
13 | CurrentUserFunc func(ctx context.Context) (buildkite.User, *buildkite.Response, error)
14 | }
15 |
16 | func (m *MockUserClient) CurrentUser(ctx context.Context) (buildkite.User, *buildkite.Response, error) {
17 | if m.CurrentUserFunc != nil {
18 | return m.CurrentUserFunc(ctx)
19 | }
20 | return buildkite.User{}, nil, nil
21 | }
22 |
23 | func TestCurrentUser(t *testing.T) {
24 | assert := require.New(t)
25 |
26 | ctx := context.Background()
27 | client := &MockUserClient{
28 | CurrentUserFunc: func(ctx context.Context) (buildkite.User, *buildkite.Response, error) {
29 | return buildkite.User{
30 | ID: "123",
31 | Name: "Test User",
32 | Email: "user@example.com",
33 | CreatedAt: &buildkite.Timestamp{},
34 | }, &buildkite.Response{
35 | Response: &http.Response{
36 | StatusCode: 200,
37 | },
38 | }, nil
39 | },
40 | }
41 |
42 | tool, handler, scopes := CurrentUser(client)
43 | assert.Equal([]string{"read_user"}, scopes)
44 | assert.NotNil(tool)
45 | assert.NotNil(handler)
46 |
47 | request := createMCPRequest(t, map[string]any{})
48 | result, err := handler(ctx, request)
49 | assert.NoError(err)
50 |
51 | textContent := getTextResult(t, result)
52 |
53 | assert.JSONEq(`{"id":"123","name":"Test User","email":"user@example.com","created_at":"0001-01-01T00:00:00Z"}`, textContent.Text)
54 | }
55 |
--------------------------------------------------------------------------------
/AGENT.md:
--------------------------------------------------------------------------------
1 | # AGENT.md - Buildkite MCP Server
2 |
3 | ## Build/Test Commands
4 | - `make build` - Build the binary
5 | - `make test` - Run all tests with coverage
6 | - `go test ./internal/buildkite/...` - Run tests for specific package
7 | - `go test -run TestName` - Run single test by name
8 | - `make lint` - Run golangci-lint
9 | - `make lint-fix` - Run golangci-lint with auto-fix
10 | - `make check` - Run linting and tests
11 | - `make all` - Full build pipeline
12 |
13 | ## Architecture
14 | - **Main binary**: `cmd/buildkite-mcp-server/main.go` - MCP server for Buildkite API access
15 | - **Core packages**: `internal/buildkite/` - API wrappers, `internal/commands/` - CLI commands
16 | - **Key dependencies**: `github.com/mark3labs/mcp-go` (MCP protocol), `github.com/buildkite/go-buildkite/v4` (API client)
17 | - **Configuration**: Environment variables (BUILDKITE_API_TOKEN, OTEL tracing)
18 | - **CI/CD**: `buildkite` organization, `buildkite-mcp-server` pipeline slug for build and test (`.buildkite/pipeline.yml`), `buildkite-mcp-server-release` pipeline slug for releases (`.buildkite/pipeline.release.yml`)
19 |
20 | ## Code Style
21 | - Use `zerolog` for logging, `testify/require` for tests
22 | - Mock interfaces for testing (see `MockPipelinesClient` pattern)
23 | - Import groups: stdlib, external, internal (`github.com/buildkite/buildkite-mcp-server/internal/...`)
24 | - Error handling: return errors up the stack, log at top level
25 | - Package names: lowercase, descriptive (buildkite, commands, trace, tokens)
26 | - Use contexts for cancellation and tracing throughout
27 | - Use `mcp.NewToolResultError` or `mcp.NewToolResultErrorFromErr` to handle errors in tools.
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | # Variables
2 | BINARY_NAME := buildkite-mcp-server
3 | CMD_PACKAGE := ./cmd/$(BINARY_NAME)
4 | DOCS_PACKAGE := ./cmd/update-docs
5 | COVERAGE_FILE := coverage.out
6 |
7 | # Default target
8 | .DEFAULT_GOAL := build
9 |
10 | # Help target
11 | .PHONY: help
12 | help: ## Show this help message
13 | @echo "Available targets:"
14 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}'
15 |
16 | .PHONY: build
17 | build: ## Build the binary
18 | go build -o $(BINARY_NAME) $(CMD_PACKAGE)
19 |
20 | .PHONY: install
21 | install: ## Install the binary
22 | go install $(CMD_PACKAGE)
23 |
24 | .PHONY: snapshot
25 | snapshot: ## Build snapshot with goreleaser
26 | goreleaser build --snapshot --clean --single-target
27 |
28 | .PHONY: update-docs
29 | update-docs: ## Update documentation
30 | go run $(DOCS_PACKAGE)
31 |
32 | .PHONY: run
33 | run: ## Run the application with stdio
34 | go run $(CMD_PACKAGE) stdio
35 |
36 | .PHONY: test
37 | test: ## Run tests with coverage
38 | go test -coverprofile $(COVERAGE_FILE) -covermode atomic -v ./...
39 |
40 | .PHONY: test-coverage
41 | test-coverage: test ## Run tests and show coverage report
42 | go tool cover -html=$(COVERAGE_FILE)
43 |
44 | .PHONY: lint
45 | lint: ## Run linter
46 | golangci-lint run ./...
47 |
48 | .PHONY: lint-fix
49 | lint-fix: ## Run linter with auto-fix
50 | golangci-lint run --fix ./...
51 |
52 | .PHONY: clean
53 | clean: ## Clean build artifacts
54 | rm -f $(BINARY_NAME) $(COVERAGE_FILE)
55 | go clean
56 |
57 | .PHONY: deps
58 | deps: ## Download and tidy dependencies
59 | go mod download
60 | go mod tidy
61 |
62 | .PHONY: check
63 | check: lint test ## Run all checks (lint + test)
64 |
65 | .PHONY: all
66 | all: clean deps check build ## Run full build pipeline
67 |
--------------------------------------------------------------------------------
/internal/commands/command.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "os/exec"
7 | "runtime"
8 |
9 | buildkitelogs "github.com/buildkite/buildkite-logs"
10 | gobuildkite "github.com/buildkite/go-buildkite/v4"
11 | "github.com/rs/zerolog/log"
12 | )
13 |
14 | type Globals struct {
15 | Client *gobuildkite.Client
16 | BuildkiteLogsClient *buildkitelogs.Client
17 | Version string
18 | }
19 |
20 | func UserAgent(version string) string {
21 | os := runtime.GOOS
22 | arch := runtime.GOARCH
23 |
24 | return fmt.Sprintf("buildkite-mcp-server/%s (%s; %s)", version, os, arch)
25 | }
26 |
27 | func ResolveAPIToken(token, tokenFrom1Password string) (string, error) {
28 | if token != "" && tokenFrom1Password != "" {
29 | return "", fmt.Errorf("cannot specify both --api-token and --api-token-from-1password")
30 | }
31 | if token == "" && tokenFrom1Password == "" {
32 | return "", fmt.Errorf("must specify either --api-token or --api-token-from-1password")
33 | }
34 | if token != "" {
35 | return token, nil
36 | }
37 |
38 | // Fetch the token from 1Password
39 | opToken, err := fetchTokenFrom1Password(tokenFrom1Password)
40 | if err != nil {
41 | return "", fmt.Errorf("failed to fetch API token from 1Password: %w", err)
42 | }
43 | return opToken, nil
44 | }
45 |
46 | func fetchTokenFrom1Password(opID string) (string, error) {
47 | // read the token using the 1Password CLI with `-n` to avoid a trailing newline
48 | out, err := exec.Command("op", "read", "-n", opID).Output()
49 | if err != nil {
50 | return "", expandExecErr(err)
51 | }
52 |
53 | log.Info().Msg("Fetched API token from 1Password")
54 |
55 | return string(out), nil
56 | }
57 |
58 | func expandExecErr(err error) error {
59 | var exitErr *exec.ExitError
60 | if errors.As(err, &exitErr) {
61 | return fmt.Errorf("command failed: %s", string(exitErr.Stderr))
62 | }
63 | return err
64 | }
65 |
--------------------------------------------------------------------------------
/pkg/buildkite/organizations.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/buildkite/buildkite-mcp-server/pkg/trace"
7 | "github.com/buildkite/go-buildkite/v4"
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/mark3labs/mcp-go/server"
10 | )
11 |
12 | type OrganizationsClient interface {
13 | List(ctx context.Context, options *buildkite.OrganizationListOptions) ([]buildkite.Organization, *buildkite.Response, error)
14 | }
15 |
16 | func UserTokenOrganization(client OrganizationsClient) (tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) {
17 | return mcp.NewTool("user_token_organization",
18 | mcp.WithDescription("Get the organization associated with the user token used for this request"),
19 | mcp.WithToolAnnotation(mcp.ToolAnnotation{
20 | Title: "Get Organization for User Token",
21 | ReadOnlyHint: mcp.ToBoolPtr(true),
22 | }),
23 | ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
24 | ctx, span := trace.Start(ctx, "buildkite.UserTokenOrganization")
25 | defer span.End()
26 |
27 | orgs, _, err := client.List(ctx, &buildkite.OrganizationListOptions{})
28 | if err != nil {
29 | return mcp.NewToolResultError(err.Error()), nil
30 | }
31 |
32 | if len(orgs) == 0 {
33 | return mcp.NewToolResultError("no organization found for the current user token"), nil
34 | }
35 |
36 | return mcpTextResult(span, &orgs[0])
37 | }, []string{"read_organizations"}
38 | }
39 |
40 | func HandleUserTokenOrganizationPrompt(
41 | ctx context.Context,
42 | request mcp.GetPromptRequest,
43 | ) (*mcp.GetPromptResult, error) {
44 | return &mcp.GetPromptResult{
45 | Description: "When asked for detail of a users pipelines start by looking up the user's token organization",
46 | Messages: []mcp.PromptMessage{
47 | {
48 | Role: mcp.RoleUser,
49 | Content: mcp.TextContent{
50 | Type: "text",
51 | Text: "When asked for detail of a users pipelines start by looking up the user's token organization",
52 | },
53 | },
54 | },
55 | }, nil
56 | }
57 |
--------------------------------------------------------------------------------
/pkg/buildkite/access_token_test.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 | "io"
6 | "net/http"
7 | "strings"
8 | "testing"
9 | "time"
10 |
11 | "github.com/buildkite/go-buildkite/v4"
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | type MockAccessTokenClient struct {
16 | GetFunc func(ctx context.Context) (buildkite.AccessToken, *buildkite.Response, error)
17 | }
18 |
19 | func (m *MockAccessTokenClient) Get(ctx context.Context) (buildkite.AccessToken, *buildkite.Response, error) {
20 | if m.GetFunc != nil {
21 | return m.GetFunc(ctx)
22 | }
23 | return buildkite.AccessToken{}, nil, nil
24 | }
25 |
26 | func TestAccessToken(t *testing.T) {
27 | assert := require.New(t)
28 | testTime := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
29 |
30 | ctx := context.Background()
31 | client := &MockAccessTokenClient{
32 | GetFunc: func(ctx context.Context) (buildkite.AccessToken, *buildkite.Response, error) {
33 | return buildkite.AccessToken{
34 | UUID: "123",
35 | Scopes: []string{"read_build", "read_pipeline"},
36 | Description: "Test token",
37 | User: struct {
38 | Name string `json:"name"`
39 | Email string `json:"email"`
40 | }{
41 | Name: "Test User",
42 | Email: "test@example.com",
43 | },
44 | CreatedAt: &buildkite.Timestamp{Time: testTime},
45 |
46 | // Add other fields as needed
47 | }, &buildkite.Response{
48 | Response: &http.Response{
49 | StatusCode: 200,
50 | Body: io.NopCloser(strings.NewReader(`{"id": "123"}`)),
51 | },
52 | }, nil
53 | },
54 | }
55 |
56 | tool, handler, _ := AccessToken(client)
57 | assert.NotNil(t, tool)
58 | assert.NotNil(t, handler)
59 |
60 | request := createMCPRequest(t, map[string]any{})
61 | result, err := handler(ctx, request)
62 | assert.NoError(err)
63 |
64 | textContent := getTextResult(t, result)
65 |
66 | assert.JSONEq(`{"uuid":"123","scopes":["read_build","read_pipeline"],"description":"Test token","created_at":"2023-01-01T00:00:00Z","user":{"name":"Test User","email":"test@example.com"}}`, textContent.Text)
67 | }
68 |
--------------------------------------------------------------------------------
/cmd/update-docs/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "os"
7 | "regexp"
8 | "strings"
9 |
10 | "github.com/buildkite/buildkite-mcp-server/pkg/server"
11 | gobuildkite "github.com/buildkite/go-buildkite/v4"
12 | mcpserver "github.com/mark3labs/mcp-go/server"
13 | )
14 |
15 | const (
16 | readmePath = "README.md"
17 | // Markers for the tools section in the README
18 | toolsSectionStart = "## 🛠️ Tools & Features"
19 | toolsSectionEnd = "## 📸 Screenshots"
20 | )
21 |
22 | func main() {
23 | // Create a dummy client to initialize tools
24 | client := &gobuildkite.Client{}
25 |
26 | // Collect all tools (pass nil for ParquetClient since this is just for docs)
27 | tools := server.BuildkiteTools(client, nil)
28 |
29 | // Generate markdown documentation for the tools
30 | toolsDocs := generateToolsDocs(tools)
31 |
32 | // Update the README
33 | updateReadme(toolsDocs)
34 | }
35 |
36 | func generateToolsDocs(tools []mcpserver.ServerTool) string {
37 | var buffer strings.Builder
38 |
39 | buffer.WriteString(toolsSectionStart + "\n\n| Tool | Description |\n|------|-------------|\n")
40 |
41 | for _, st := range tools {
42 | buffer.WriteString(fmt.Sprintf("| `%s` | %s |\n", st.Tool.Name, st.Tool.Description))
43 | }
44 |
45 | buffer.WriteString("\n---\n\n")
46 |
47 | return buffer.String()
48 | }
49 |
50 | func updateReadme(toolsDocs string) {
51 | // Read the current README
52 | content, err := os.ReadFile(readmePath)
53 | if err != nil {
54 | log.Fatalf("Error reading README: %v", err)
55 | }
56 |
57 | contentStr := string(content)
58 |
59 | // Define the regular expression to find the tools section
60 | re := regexp.MustCompile(`(?s)` + regexp.QuoteMeta(toolsSectionStart) + `.*?` + regexp.QuoteMeta(toolsSectionEnd))
61 |
62 | // Replace the tools section with the new content plus the example line
63 | newContent := re.ReplaceAllString(contentStr, toolsDocs+toolsSectionEnd)
64 |
65 | // Write the updated README
66 | err = os.WriteFile(readmePath, []byte(newContent), 0o600)
67 | if err != nil {
68 | log.Fatalf("Error writing README: %v", err)
69 | }
70 |
71 | fmt.Println("README updated successfully!")
72 | }
73 |
--------------------------------------------------------------------------------
/pkg/buildkite/tests.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/buildkite/buildkite-mcp-server/pkg/trace"
7 | "github.com/buildkite/go-buildkite/v4"
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/mark3labs/mcp-go/server"
10 | "go.opentelemetry.io/otel/attribute"
11 | )
12 |
13 | type TestsClient interface {
14 | Get(ctx context.Context, org, slug, testID string) (buildkite.Test, *buildkite.Response, error)
15 | }
16 |
17 | func GetTest(client TestsClient) (tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) {
18 | return mcp.NewTool("get_test",
19 | mcp.WithDescription("Get a specific test in Buildkite Test Engine. This provides additional metadata for failed test executions"),
20 | mcp.WithString("org_slug",
21 | mcp.Required(),
22 | ),
23 | mcp.WithString("test_suite_slug",
24 | mcp.Required(),
25 | ),
26 | mcp.WithString("test_id",
27 | mcp.Required(),
28 | ),
29 | mcp.WithToolAnnotation(mcp.ToolAnnotation{
30 | Title: "Get Test",
31 | ReadOnlyHint: mcp.ToBoolPtr(true),
32 | }),
33 | ),
34 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
35 | ctx, span := trace.Start(ctx, "buildkite.GetTest")
36 | defer span.End()
37 |
38 | orgSlug, err := request.RequireString("org_slug")
39 | if err != nil {
40 | return mcp.NewToolResultError(err.Error()), nil
41 | }
42 |
43 | testSuiteSlug, err := request.RequireString("test_suite_slug")
44 | if err != nil {
45 | return mcp.NewToolResultError(err.Error()), nil
46 | }
47 |
48 | testID, err := request.RequireString("test_id")
49 | if err != nil {
50 | return mcp.NewToolResultError(err.Error()), nil
51 | }
52 |
53 | span.SetAttributes(
54 | attribute.String("org_slug", orgSlug),
55 | attribute.String("test_suite_slug", testSuiteSlug),
56 | attribute.String("test_id", testID),
57 | )
58 |
59 | test, _, err := client.Get(ctx, orgSlug, testSuiteSlug, testID)
60 | if err != nil {
61 | return mcp.NewToolResultError(err.Error()), nil
62 | }
63 |
64 | return mcpTextResult(span, &test)
65 | }, []string{"read_suites"}
66 | }
67 |
--------------------------------------------------------------------------------
/pkg/buildkite/tests_test.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 | "io"
6 | "net/http"
7 | "strings"
8 | "testing"
9 |
10 | "github.com/buildkite/go-buildkite/v4"
11 | "github.com/stretchr/testify/require"
12 | )
13 |
14 | type MockTestsClient struct {
15 | GetFunc func(ctx context.Context, org, slug, testID string) (buildkite.Test, *buildkite.Response, error)
16 | }
17 |
18 | func (m *MockTestsClient) Get(ctx context.Context, org, slug, testID string) (buildkite.Test, *buildkite.Response, error) {
19 | if m.GetFunc != nil {
20 | return m.GetFunc(ctx, org, slug, testID)
21 | }
22 | return buildkite.Test{}, nil, nil
23 | }
24 |
25 | var _ TestsClient = (*MockTestsClient)(nil)
26 |
27 | func TestGetTest(t *testing.T) {
28 | assert := require.New(t)
29 |
30 | client := &MockTestsClient{
31 | GetFunc: func(ctx context.Context, org, slug, testID string) (buildkite.Test, *buildkite.Response, error) {
32 | return buildkite.Test{
33 | ID: "test-123",
34 | Name: "Example Test",
35 | Location: "spec/example_test.rb",
36 | }, &buildkite.Response{
37 | Response: &http.Response{
38 | StatusCode: 200,
39 | Body: io.NopCloser(strings.NewReader(`{"id": "test-123"}`)),
40 | },
41 | }, nil
42 | },
43 | }
44 |
45 | tool, handler, _ := GetTest(client)
46 | assert.NotNil(tool)
47 | assert.NotNil(handler)
48 |
49 | // Test the tool schema
50 | assert.Equal("get_test", tool.Name)
51 | assert.Contains(tool.Description, "specific test")
52 |
53 | // Test required parameters
54 | params := tool.InputSchema.Properties
55 | assert.Contains(params, "org_slug")
56 | assert.Contains(params, "test_suite_slug")
57 | assert.Contains(params, "test_id")
58 |
59 | // Verify org is required
60 | orgParam := params["org_slug"].(map[string]any)
61 | assert.Equal("string", orgParam["type"])
62 |
63 | // Verify test_suite_slug is required
64 | testSuiteParam := params["test_suite_slug"].(map[string]any)
65 | assert.Equal("string", testSuiteParam["type"])
66 |
67 | // Verify test_id is required
68 | testIDParam := params["test_id"].(map[string]any)
69 | assert.Equal("string", testIDParam["type"])
70 | }
71 |
--------------------------------------------------------------------------------
/.buildkite/pipeline.release.yml:
--------------------------------------------------------------------------------
1 | agents:
2 | queue: hosted
3 |
4 | steps:
5 | - label: ":terminal: build ({{matrix}})"
6 | matrix:
7 | - "darwin"
8 | - "linux"
9 | - "windows"
10 | artifact_paths:
11 | - dist/**/*
12 | plugins:
13 | - docker-compose#v5.10.0:
14 | command:
15 | - .buildkite/release.sh
16 | - release
17 | - --clean
18 | - --split
19 | config: .buildkite/docker-compose.yaml
20 | env:
21 | - GOOS={{matrix}}
22 | mount-buildkite-agent: true
23 | run: goreleaser
24 | shell: false
25 | tty: true
26 | progress: plain
27 |
28 | - wait: ~
29 |
30 | - label: ":rocket: :github: MCP Release"
31 | artifact_paths:
32 | - dist/**/*
33 | env:
34 | AWS_REGION: us-east-1
35 | plugins:
36 | - aws-assume-role-with-web-identity:
37 | role-arn: arn:aws:iam::445615400570:role/pipeline-buildkite-buildkite-mcp-server-release
38 | session-tags:
39 | - organization_id
40 | - organization_slug
41 | - pipeline_slug
42 | - aws-ssm#v1.0.0:
43 | parameters:
44 | GITHUB_USER: /pipelines/buildkite/buildkite-mcp-server-release/github-user
45 | GITHUB_TOKEN: /pipelines/buildkite/buildkite-mcp-server-release/github-token
46 | DOCKERHUB_PASSWORD: /pipelines/buildkite/buildkite-mcp-server-release/dockerhub-password
47 | DOCKERHUB_USER: /pipelines/buildkite/buildkite-mcp-server-release/dockerhub-user
48 | - artifacts#v1.9.3:
49 | download:
50 | - dist/**/*
51 | - docker-compose#v5.10.0:
52 | command:
53 | - .buildkite/release.sh
54 | - continue
55 | - --merge
56 | config: .buildkite/docker-compose.yaml
57 | env:
58 | - GITHUB_USER
59 | - GITHUB_TOKEN
60 | - DOCKERHUB_USER
61 | - DOCKERHUB_PASSWORD
62 | mount-buildkite-agent: true
63 | run: goreleaser
64 | shell: false
65 | tty: true
66 | progress: plain
67 |
--------------------------------------------------------------------------------
/server.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
3 | "name": "io.github.buildkite/buildkite-mcp-server",
4 | "description": "MCP server exposing Buildkite API data (pipelines, builds, jobs, tests) to AI tooling and editors.",
5 | "status": "active",
6 | "version": "0.7.0",
7 | "repository": {
8 | "url": "https://github.com/buildkite/buildkite-mcp-server",
9 | "source": "github",
10 | "id": "962909011"
11 | },
12 | "packages": [
13 | {
14 | "registryType": "oci",
15 | "registryBaseUrl": "https://ghcr.io",
16 | "identifier": "buildkite/buildkite-mcp-server",
17 | "version": "0.7.0",
18 | "transport": {
19 | "type": "stdio"
20 | },
21 | "runtimeHint": "docker",
22 | "runtimeArguments": [
23 | {
24 | "type": "positional",
25 | "value": "run",
26 | "description": "The runtime command to execute"
27 | },
28 | {
29 | "type": "named",
30 | "name": "-i",
31 | "description": "Run container in interactive mode"
32 | },
33 | {
34 | "type": "named",
35 | "name": "--rm",
36 | "description": "Automatically remove the container when it exits"
37 | },
38 | {
39 | "type": "named",
40 | "name": "-e",
41 | "description": "Set an environment variable in the runtime"
42 | },
43 | {
44 | "type": "positional",
45 | "value": "BUILDKITE_API_TOKEN",
46 | "description": "Environment variable name"
47 | },
48 | {
49 | "type": "positional",
50 | "value": "ghcr.io/buildkite/buildkite-mcp-server:0.7.0",
51 | "description": "The container image to run"
52 | }
53 | ],
54 | "environmentVariables": [
55 | {
56 | "name": "BUILDKITE_API_TOKEN",
57 | "description": "Buildkite API token for authentication. Get one from https://buildkite.com/user/api-access-tokens",
58 | "isRequired": true,
59 | "format": "string",
60 | "isSecret": true
61 | }
62 | ]
63 | }
64 | ]
65 | }
66 |
--------------------------------------------------------------------------------
/pkg/buildkite/annotations_test.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "testing"
7 |
8 | "github.com/buildkite/go-buildkite/v4"
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | type MockAnnotationsClient struct {
13 | ListByBuildFunc func(ctx context.Context, org, pipelineSlug, buildNumber string, opts *buildkite.AnnotationListOptions) ([]buildkite.Annotation, *buildkite.Response, error)
14 | GetFunc func(ctx context.Context, org, pipelineSlug, buildNumber, id string) (buildkite.Annotation, *buildkite.Response, error)
15 | }
16 |
17 | func (m *MockAnnotationsClient) ListByBuild(ctx context.Context, org, pipelineSlug, buildNumber string, opts *buildkite.AnnotationListOptions) ([]buildkite.Annotation, *buildkite.Response, error) {
18 | if m.ListByBuildFunc != nil {
19 | return m.ListByBuildFunc(ctx, org, pipelineSlug, buildNumber, opts)
20 | }
21 | return nil, nil, nil
22 | }
23 |
24 | var _ AnnotationsClient = (*MockAnnotationsClient)(nil)
25 |
26 | func TestListAnnotations(t *testing.T) {
27 | assert := require.New(t)
28 |
29 | ctx := context.Background()
30 |
31 | client := &MockAnnotationsClient{
32 | ListByBuildFunc: func(ctx context.Context, org, pipelineSlug, buildNumber string, opts *buildkite.AnnotationListOptions) ([]buildkite.Annotation, *buildkite.Response, error) {
33 | return []buildkite.Annotation{
34 | {
35 | ID: "1",
36 | BodyHTML: "Test annotation 1",
37 | },
38 | {
39 | ID: "2",
40 | BodyHTML: "Test annotation 2",
41 | },
42 | }, &buildkite.Response{
43 | Response: &http.Response{
44 | StatusCode: 200,
45 | },
46 | }, nil
47 | },
48 | }
49 |
50 | tool, handler, _ := ListAnnotations(client)
51 | assert.NotNil(tool)
52 | assert.NotNil(handler)
53 | request := createMCPRequest(t, map[string]any{
54 | "org_slug": "org",
55 | "pipeline_slug": "pipeline",
56 | "build_number": "1",
57 | })
58 | result, err := handler(ctx, request)
59 | assert.NoError(err)
60 | textContent := getTextResult(t, result)
61 |
62 | assert.JSONEq(`{"headers":{"Link":""},"items":[{"id":"1","body_html":"Test annotation 1"},{"id":"2","body_html":"Test annotation 2"}]}`, textContent.Text)
63 | }
64 |
--------------------------------------------------------------------------------
/.buildkite/pipeline.yml:
--------------------------------------------------------------------------------
1 | agents:
2 | queue: hosted
3 |
4 | cache:
5 | name: "golang-cache"
6 | paths:
7 | - "~/gocache"
8 | - "~/gomodcache"
9 | size: "100g"
10 |
11 | steps:
12 | - group: ":mag: Quality Checks"
13 | key: quality-checks
14 | steps:
15 | - name: ":golangci-lint: lint"
16 | command: golangci-lint run --verbose --timeout 3m
17 | plugins:
18 | - docker-compose#v5.10.0:
19 | config: .buildkite/docker-compose.yaml
20 | run: golangci-lint
21 | tty: true
22 | progress: plain
23 |
24 | - name: ":go: test"
25 | artifact_paths:
26 | - cover-tree.svg
27 | commands:
28 | - go test -coverprofile cover.out ./...
29 | - go run github.com/nikolaydubina/go-cover-treemap@latest -coverprofile cover.out > cover-tree.svg
30 | - echo 'Coverage tree map

' | buildkite-agent annotate --style "info"
31 | plugins:
32 | - docker-compose#v5.10.0:
33 | config: .buildkite/docker-compose.yaml
34 | run: golangci-lint
35 | tty: true
36 | mount-buildkite-agent: true
37 | progress: plain
38 |
39 | - label: ":terminal: build ({{matrix}})"
40 | depends_on: quality-checks
41 | matrix:
42 | - "darwin"
43 | - "linux"
44 | - "windows"
45 | artifact_paths:
46 | - dist/**/*
47 | plugins:
48 | - docker-compose#v5.10.0:
49 | command:
50 | - .buildkite/release.sh
51 | - release
52 | - --clean
53 | - --snapshot
54 | - --split
55 | config: .buildkite/docker-compose.yaml
56 | entrypoint: /bin/bash
57 | env:
58 | - GOOS={{matrix}}
59 | mount-buildkite-agent: true
60 | run: goreleaser
61 | shell: false
62 | tty: true
63 | progress: plain
64 |
65 | # validate the docker file builds correctly as this is used by docker hub for publishing the mcp into their registry
66 | - label: ":docker: build image"
67 | depends_on: quality-checks
68 | command: docker build -t buildkite-mcp-server:latest -f Dockerfile.local .
69 |
--------------------------------------------------------------------------------
/internal/commands/http.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net"
7 | "net/http"
8 | "time"
9 |
10 | "github.com/buildkite/buildkite-mcp-server/pkg/server"
11 | "github.com/buildkite/buildkite-mcp-server/pkg/toolsets"
12 | mcpserver "github.com/mark3labs/mcp-go/server"
13 | "github.com/rs/zerolog/log"
14 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
15 | )
16 |
17 | type HTTPCmd struct {
18 | Listen string `help:"The address to listen on." default:"localhost:3000" env:"HTTP_LISTEN_ADDR"`
19 | UseSSE bool `help:"Use deprecated SSS transport instead of Streamable HTTP." default:"false"`
20 | EnabledToolsets []string `help:"Comma-separated list of toolsets to enable (e.g., 'pipelines,builds,clusters'). Use 'all' to enable all toolsets." default:"all" env:"BUILDKITE_TOOLSETS"`
21 | ReadOnly bool `help:"Enable read-only mode, which filters out write operations from all toolsets." default:"false" env:"BUILDKITE_READ_ONLY"`
22 | }
23 |
24 | func (c *HTTPCmd) Run(ctx context.Context, globals *Globals) error {
25 | // Validate the enabled toolsets
26 | if err := toolsets.ValidateToolsets(c.EnabledToolsets); err != nil {
27 | return err
28 | }
29 |
30 | mcpServer := server.NewMCPServer(globals.Version, globals.Client, globals.BuildkiteLogsClient,
31 | server.WithReadOnly(c.ReadOnly), server.WithToolsets(c.EnabledToolsets...))
32 |
33 | listener, err := net.Listen("tcp", c.Listen)
34 | if err != nil {
35 | return fmt.Errorf("failed to listen on %s: %w", c.Listen, err)
36 | }
37 | logEvent := log.Ctx(ctx).Info().Str("address", c.Listen)
38 |
39 | mux := http.NewServeMux()
40 | srv := newServerWithTimeouts(mux)
41 |
42 | mux.HandleFunc("/health", healthHandler)
43 |
44 | if c.UseSSE {
45 | handler := mcpserver.NewSSEServer(mcpServer)
46 | mux.Handle("/sse", handler)
47 | logEvent.Str("transport", "sse").Str("endpoint", fmt.Sprintf("http://%s/sse", listener.Addr())).Msg("Starting SSE HTTP server")
48 | } else {
49 | handler := mcpserver.NewStreamableHTTPServer(mcpServer)
50 | mux.Handle("/mcp", handler)
51 | logEvent.Str("transport", "streamable-http").Str("endpoint", fmt.Sprintf("http://%s/mcp", listener.Addr())).Msg("Starting Streamable HTTP server")
52 | }
53 |
54 | return srv.Serve(listener)
55 | }
56 |
57 | func newServerWithTimeouts(mux *http.ServeMux) *http.Server {
58 | return &http.Server{
59 | Handler: otelhttp.NewHandler(mux, "mcp-server"),
60 | ReadHeaderTimeout: 30 * time.Second,
61 | ReadTimeout: 30 * time.Second,
62 | WriteTimeout: 30 * time.Second,
63 | IdleTimeout: 60 * time.Second,
64 | }
65 | }
66 |
67 | func healthHandler(w http.ResponseWriter, _ *http.Request) {
68 | w.WriteHeader(http.StatusOK)
69 | }
70 |
--------------------------------------------------------------------------------
/pkg/buildkite/clusters_test.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "testing"
7 |
8 | "github.com/buildkite/go-buildkite/v4"
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | var _ ClustersClient = (*mockClustersClient)(nil)
13 |
14 | type mockClustersClient struct {
15 | ListFunc func(ctx context.Context, org string, opts *buildkite.ClustersListOptions) ([]buildkite.Cluster, *buildkite.Response, error)
16 | GetFunc func(ctx context.Context, org, id string) (buildkite.Cluster, *buildkite.Response, error)
17 | }
18 |
19 | func (m *mockClustersClient) List(ctx context.Context, org string, opts *buildkite.ClustersListOptions) ([]buildkite.Cluster, *buildkite.Response, error) {
20 | if m.ListFunc != nil {
21 | return m.ListFunc(ctx, org, opts)
22 | }
23 | return nil, nil, nil
24 | }
25 |
26 | func (m *mockClustersClient) Get(ctx context.Context, org, id string) (buildkite.Cluster, *buildkite.Response, error) {
27 | if m.GetFunc != nil {
28 | return m.GetFunc(ctx, org, id)
29 | }
30 | return buildkite.Cluster{}, nil, nil
31 | }
32 |
33 | func TestListClusters(t *testing.T) {
34 | assert := require.New(t)
35 |
36 | ctx := context.Background()
37 | client := &mockClustersClient{
38 | ListFunc: func(ctx context.Context, org string, opts *buildkite.ClustersListOptions) ([]buildkite.Cluster, *buildkite.Response, error) {
39 | return []buildkite.Cluster{
40 | {
41 | ID: "cluster-id",
42 | Name: "cluster-name",
43 | },
44 | }, &buildkite.Response{
45 | Response: &http.Response{
46 | StatusCode: 200,
47 | },
48 | }, nil
49 | },
50 | }
51 |
52 | tool, handler, _ := ListClusters(client)
53 | assert.NotNil(tool)
54 | assert.NotNil(handler)
55 |
56 | request := createMCPRequest(t, map[string]any{
57 | "org_slug": "org",
58 | })
59 | result, err := handler(ctx, request)
60 | assert.NoError(err)
61 |
62 | textContent := getTextResult(t, result)
63 | assert.JSONEq(`{"headers":{"Link":""},"items":[{"id":"cluster-id","name":"cluster-name","created_by":{},"maintainers":{}}]}`, textContent.Text)
64 | }
65 |
66 | func TestGetCluster(t *testing.T) {
67 | assert := require.New(t)
68 |
69 | ctx := context.Background()
70 | client := &mockClustersClient{
71 | GetFunc: func(ctx context.Context, org, id string) (buildkite.Cluster, *buildkite.Response, error) {
72 | return buildkite.Cluster{
73 | ID: "cluster-id",
74 | Name: "cluster-name",
75 | }, &buildkite.Response{
76 | Response: &http.Response{
77 | StatusCode: 200,
78 | },
79 | }, nil
80 | },
81 | }
82 |
83 | tool, handler, _ := GetCluster(client)
84 | assert.NotNil(tool)
85 | assert.NotNil(handler)
86 |
87 | request := createMCPRequest(t, map[string]any{
88 | "org_slug": "org",
89 | "cluster_id": "cluster-id",
90 | })
91 | result, err := handler(ctx, request)
92 | assert.NoError(err)
93 |
94 | textContent := getTextResult(t, result)
95 | assert.JSONEq("{\"id\":\"cluster-id\",\"name\":\"cluster-name\",\"created_by\":{},\"maintainers\":{}}", textContent.Text)
96 | }
97 |
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | # This is an example .goreleaser.yml file with some sensible defaults.
2 | # Make sure to check the documentation at https://goreleaser.com
3 |
4 | # The lines below are called `modelines`. See `:help modeline`
5 | # Feel free to remove those if you don't want/need to use them.
6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json
7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj
8 |
9 | version: 2
10 |
11 | before:
12 | hooks:
13 | # You may remove this if you don't use go modules.
14 | - go mod tidy
15 | # you may remove this if you don't need go generate
16 | - go generate ./...
17 |
18 | builds:
19 | - id: buildkite-mcp-server
20 | binary: buildkite-mcp-server
21 | main: ./cmd/buildkite-mcp-server/main.go
22 | env:
23 | - CGO_ENABLED=0
24 | goos:
25 | - linux
26 | - windows
27 | - darwin
28 | goarch:
29 | - amd64
30 | - arm64
31 | goamd64:
32 | - v2
33 | ldflags:
34 | - -s -w -X main.version={{.Version}}
35 | flags:
36 | - -trimpath
37 | kos:
38 | - repositories:
39 | - ghcr.io/buildkite/buildkite-mcp-server
40 | - docker.io/buildkite/mcp-server
41 | build: buildkite-mcp-server
42 | main: ./cmd/buildkite-mcp-server/
43 | creation_time: "{{.CommitTimestamp}}"
44 | base_image: 'cgr.dev/chainguard/static:latest'
45 | tags:
46 | - '{{.Version}}'
47 | - latest
48 | labels:
49 | org.opencontainers.image.authors: Buildkite Inc. https://buildkite.com
50 | org.opencontainers.image.source: https://github.com/buildkite/buildkite-mcp-server
51 | org.opencontainers.image.created: "{{.Date}}"
52 | org.opencontainers.image.title: "{{.ProjectName}}"
53 | org.opencontainers.image.revision: "{{.FullCommit}}"
54 | org.opencontainers.image.version: "{{.Version}}"
55 | io.modelcontextprotocol.server.name: "io.github.buildkite/buildkite-mcp-server"
56 | bare: true
57 | preserve_import_paths: false
58 | # FIXME: We use GOOS and -split in our pipeline which is causing issues with the ko integration
59 | # so we disable it here when the GOOS is set to something other than linux. This avoids
60 | # the ko build to fail when running on macos or windows.
61 | disable: '{{ and (isEnvSet "GOOS") (ne .Env.GOOS "linux") }}'
62 | platforms:
63 | - linux/amd64
64 | - linux/arm64
65 | archives:
66 | - formats: ["tar.gz"]
67 | # this name template makes the OS and Arch compatible with the results of `uname`.
68 | name_template: >-
69 | {{ .ProjectName }}_
70 | {{- title .Os }}_
71 | {{- if eq .Arch "amd64" }}x86_64
72 | {{- else if eq .Arch "386" }}i386
73 | {{- else }}{{ .Arch }}{{ end }}
74 | {{- if .Arm }}v{{ .Arm }}{{ end }}
75 | # use zip for windows archives
76 | format_overrides:
77 | - goos: windows
78 | formats: ["zip"]
79 |
80 | changelog:
81 | sort: asc
82 | filters:
83 | exclude:
84 | - "^docs:"
85 | - "^test:"
86 |
87 | release:
88 | footer: >-
89 |
90 | ---
91 |
92 | Released by [GoReleaser](https://github.com/goreleaser/goreleaser).
93 |
--------------------------------------------------------------------------------
/pkg/buildkite/annotations.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/buildkite/buildkite-mcp-server/pkg/trace"
7 | "github.com/buildkite/go-buildkite/v4"
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/mark3labs/mcp-go/server"
10 | "go.opentelemetry.io/otel/attribute"
11 | )
12 |
13 | // AnnotationsClient describes the subset of the Buildkite client we need for annotations.
14 | type AnnotationsClient interface {
15 | ListByBuild(ctx context.Context, org, pipelineSlug, buildNumber string, opts *buildkite.AnnotationListOptions) ([]buildkite.Annotation, *buildkite.Response, error)
16 | }
17 |
18 | // ListAnnotations returns an MCP tool + handler pair that lists annotations for a build.
19 | func ListAnnotations(client AnnotationsClient) (tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) {
20 | return mcp.NewTool("list_annotations",
21 | mcp.WithDescription("List all annotations for a build, including their context, style (success/info/warning/error), rendered HTML content, and creation timestamps"),
22 | mcp.WithString("org_slug",
23 | mcp.Required(),
24 | ),
25 | mcp.WithString("pipeline_slug",
26 | mcp.Required(),
27 | ),
28 | mcp.WithString("build_number",
29 | mcp.Required(),
30 | ),
31 | withPagination(),
32 | mcp.WithToolAnnotation(mcp.ToolAnnotation{
33 | Title: "List Annotations",
34 | ReadOnlyHint: mcp.ToBoolPtr(true),
35 | }),
36 | ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
37 | ctx, span := trace.Start(ctx, "buildkite.ListAnnotations")
38 | defer span.End()
39 |
40 | orgSlug, err := request.RequireString("org_slug")
41 | if err != nil {
42 | return mcp.NewToolResultError(err.Error()), nil
43 | }
44 |
45 | pipelineSlug, err := request.RequireString("pipeline_slug")
46 | if err != nil {
47 | return mcp.NewToolResultError(err.Error()), nil
48 | }
49 |
50 | buildNumber, err := request.RequireString("build_number")
51 | if err != nil {
52 | return mcp.NewToolResultError(err.Error()), nil
53 | }
54 |
55 | paginationParams, err := optionalPaginationParams(request)
56 | if err != nil {
57 | return mcp.NewToolResultError(err.Error()), nil
58 | }
59 |
60 | span.SetAttributes(
61 | attribute.String("org_slug", orgSlug),
62 | attribute.String("pipeline_slug", pipelineSlug),
63 | attribute.String("build_number", buildNumber),
64 | attribute.Int("page", paginationParams.Page),
65 | attribute.Int("per_page", paginationParams.PerPage),
66 | )
67 |
68 | annotations, resp, err := client.ListByBuild(ctx, orgSlug, pipelineSlug, buildNumber, &buildkite.AnnotationListOptions{
69 | ListOptions: paginationParams,
70 | })
71 | if err != nil {
72 | return mcp.NewToolResultError(err.Error()), nil
73 | }
74 |
75 | result := PaginatedResult[buildkite.Annotation]{
76 | Items: annotations,
77 | Headers: map[string]string{
78 | "Link": resp.Header.Get("Link"),
79 | },
80 | }
81 |
82 | span.SetAttributes(
83 | attribute.Int("item_count", len(annotations)),
84 | )
85 |
86 | return mcpTextResult(span, &result)
87 | }, []string{"read_builds"}
88 | }
89 |
--------------------------------------------------------------------------------
/pkg/buildkite/cluster_queue_test.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "testing"
7 |
8 | "github.com/buildkite/go-buildkite/v4"
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | type mockClusterQueuesClient struct {
13 | ListFunc func(ctx context.Context, org, clusterID string, opts *buildkite.ClusterQueuesListOptions) ([]buildkite.ClusterQueue, *buildkite.Response, error)
14 | GetFunc func(ctx context.Context, org, clusterID, queueID string) (buildkite.ClusterQueue, *buildkite.Response, error)
15 | }
16 |
17 | func (m *mockClusterQueuesClient) List(ctx context.Context, org, clusterID string, opts *buildkite.ClusterQueuesListOptions) ([]buildkite.ClusterQueue, *buildkite.Response, error) {
18 | if m.ListFunc != nil {
19 | return m.ListFunc(ctx, org, clusterID, opts)
20 | }
21 | return nil, nil, nil
22 | }
23 |
24 | func (m *mockClusterQueuesClient) Get(ctx context.Context, org, clusterID, queueID string) (buildkite.ClusterQueue, *buildkite.Response, error) {
25 | if m.GetFunc != nil {
26 | return m.GetFunc(ctx, org, clusterID, queueID)
27 | }
28 | return buildkite.ClusterQueue{}, nil, nil
29 | }
30 |
31 | var _ ClusterQueuesClient = (*mockClusterQueuesClient)(nil)
32 |
33 | func TestListClusterQueues(t *testing.T) {
34 | assert := require.New(t)
35 |
36 | ctx := context.Background()
37 | client := &mockClusterQueuesClient{
38 | ListFunc: func(ctx context.Context, org, clusterID string, opts *buildkite.ClusterQueuesListOptions) ([]buildkite.ClusterQueue, *buildkite.Response, error) {
39 | return []buildkite.ClusterQueue{
40 | {
41 | ID: "queue-id",
42 | },
43 | }, &buildkite.Response{
44 | Response: &http.Response{
45 | StatusCode: 200,
46 | },
47 | }, nil
48 | },
49 | }
50 |
51 | tool, handler, _ := ListClusterQueues(client)
52 | assert.NotNil(tool)
53 | assert.NotNil(handler)
54 |
55 | request := createMCPRequest(t, map[string]any{
56 | "org_slug": "org",
57 | "cluster_id": "cluster-id",
58 | })
59 | result, err := handler(ctx, request)
60 | assert.NoError(err)
61 |
62 | textContent := getTextResult(t, result)
63 | assert.JSONEq(`{"headers":{"Link":""},"items":[{"id":"queue-id","dispatch_paused":false,"created_by":{}}]}`, textContent.Text)
64 | }
65 |
66 | func TestGetClusterQueue(t *testing.T) {
67 | assert := require.New(t)
68 |
69 | ctx := context.Background()
70 | client := &mockClusterQueuesClient{
71 | GetFunc: func(ctx context.Context, org, clusterID, queueID string) (buildkite.ClusterQueue, *buildkite.Response, error) {
72 | return buildkite.ClusterQueue{
73 | ID: "queue-id",
74 | }, &buildkite.Response{
75 | Response: &http.Response{
76 | StatusCode: 200,
77 | },
78 | }, nil
79 | },
80 | }
81 |
82 | tool, handler, _ := GetClusterQueue(client)
83 | assert.NotNil(tool)
84 | assert.NotNil(handler)
85 |
86 | request := createMCPRequest(t, map[string]any{
87 | "org_slug": "org",
88 | "cluster_id": "cluster-id",
89 | "queue_id": "queue-id",
90 | })
91 | result, err := handler(ctx, request)
92 | assert.NoError(err)
93 |
94 | textContent := getTextResult(t, result)
95 | assert.JSONEq("{\"id\":\"queue-id\",\"dispatch_paused\":false,\"created_by\":{}}", textContent.Text)
96 | }
97 |
--------------------------------------------------------------------------------
/pkg/buildkite/test_executions.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/buildkite/buildkite-mcp-server/pkg/trace"
7 | "github.com/buildkite/go-buildkite/v4"
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/mark3labs/mcp-go/server"
10 | "go.opentelemetry.io/otel/attribute"
11 | )
12 |
13 | type TestExecutionsClient interface {
14 | GetFailedExecutions(ctx context.Context, org, slug, runID string, opt *buildkite.FailedExecutionsOptions) ([]buildkite.FailedExecution, *buildkite.Response, error)
15 | }
16 |
17 | func GetFailedTestExecutions(client TestExecutionsClient) (tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) {
18 | return mcp.NewTool("get_failed_executions",
19 | mcp.WithDescription("Get failed test executions for a specific test run in Buildkite Test Engine. Optionally get the expanded failure details such as full error messages and stack traces."),
20 | mcp.WithString("org_slug",
21 | mcp.Required(),
22 | ),
23 | mcp.WithString("test_suite_slug",
24 | mcp.Required(),
25 | ),
26 | mcp.WithString("run_id",
27 | mcp.Required(),
28 | ),
29 | mcp.WithBoolean("include_failure_expanded",
30 | mcp.Description("Include the expanded failure details such as full error messages and stack traces. This can be used to explain and diganose the cause of test failures."),
31 | ),
32 | withClientSidePagination(),
33 | mcp.WithToolAnnotation(mcp.ToolAnnotation{
34 | Title: "Get Failed Test Executions",
35 | ReadOnlyHint: mcp.ToBoolPtr(true),
36 | }),
37 | ),
38 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
39 | ctx, span := trace.Start(ctx, "buildkite.GetFailedExecutions")
40 | defer span.End()
41 |
42 | orgSlug, err := request.RequireString("org_slug")
43 | if err != nil {
44 | return mcp.NewToolResultError(err.Error()), nil
45 | }
46 |
47 | testSuiteSlug, err := request.RequireString("test_suite_slug")
48 | if err != nil {
49 | return mcp.NewToolResultError(err.Error()), nil
50 | }
51 |
52 | runID, err := request.RequireString("run_id")
53 | if err != nil {
54 | return mcp.NewToolResultError(err.Error()), nil
55 | }
56 |
57 | includeFailureExpanded := request.GetBool("include_failure_expanded", false)
58 |
59 | // Get client-side pagination parameters (always enabled)
60 | paginationParams := getClientSidePaginationParams(request)
61 |
62 | span.SetAttributes(
63 | attribute.String("org_slug", orgSlug),
64 | attribute.String("test_suite_slug", testSuiteSlug),
65 | attribute.String("run_id", runID),
66 | attribute.Bool("include_failure_expanded", includeFailureExpanded),
67 | attribute.Int("page", paginationParams.Page),
68 | attribute.Int("per_page", paginationParams.PerPage),
69 | )
70 |
71 | options := &buildkite.FailedExecutionsOptions{
72 | IncludeFailureExpanded: includeFailureExpanded,
73 | }
74 |
75 | failedExecutions, _, err := client.GetFailedExecutions(ctx, orgSlug, testSuiteSlug, runID, options)
76 | if err != nil {
77 | return mcp.NewToolResultError(err.Error()), nil
78 | }
79 |
80 | // Always apply client-side pagination
81 | result := applyClientSidePagination(failedExecutions, paginationParams)
82 |
83 | span.SetAttributes(
84 | attribute.Int("item_count", len(failedExecutions)),
85 | )
86 |
87 | return mcpTextResult(span, &result)
88 | }, []string{"read_suites"}
89 | }
90 |
--------------------------------------------------------------------------------
/pkg/buildkite/organizations_test.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "testing"
7 |
8 | "github.com/buildkite/go-buildkite/v4"
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | type MockOrganizationsClient struct {
13 | ListFunc func(ctx context.Context, options *buildkite.OrganizationListOptions) ([]buildkite.Organization, *buildkite.Response, error)
14 | }
15 |
16 | func (m *MockOrganizationsClient) List(ctx context.Context, options *buildkite.OrganizationListOptions) ([]buildkite.Organization, *buildkite.Response, error) {
17 | if m.ListFunc != nil {
18 | return m.ListFunc(ctx, options)
19 | }
20 | return nil, nil, nil
21 | }
22 |
23 | func TestUserTokenOrganization(t *testing.T) {
24 | assert := require.New(t)
25 |
26 | ctx := context.Background()
27 | client := &MockOrganizationsClient{
28 | ListFunc: func(ctx context.Context, options *buildkite.OrganizationListOptions) ([]buildkite.Organization, *buildkite.Response, error) {
29 | return []buildkite.Organization{
30 | {
31 | Slug: "test-org",
32 | Name: "Test Organization",
33 | },
34 | }, &buildkite.Response{
35 | Response: &http.Response{
36 | StatusCode: 200,
37 | },
38 | }, nil
39 | },
40 | }
41 |
42 | tool, handler, _ := UserTokenOrganization(client)
43 | assert.NotNil(tool)
44 | assert.NotNil(handler)
45 |
46 | request := createMCPRequest(t, map[string]any{})
47 | result, err := handler(ctx, request)
48 | assert.NoError(err)
49 |
50 | textContent := getTextResult(t, result)
51 |
52 | assert.JSONEq(`{"name":"Test Organization","slug":"test-org"}`, textContent.Text)
53 | }
54 |
55 | func TestUserTokenOrganizationError(t *testing.T) {
56 | assert := require.New(t)
57 |
58 | ctx := context.Background()
59 | client := &MockOrganizationsClient{
60 | ListFunc: func(ctx context.Context, options *buildkite.OrganizationListOptions) ([]buildkite.Organization, *buildkite.Response, error) {
61 | resp := &http.Response{
62 | Request: &http.Request{Method: "GET"},
63 | StatusCode: 500,
64 | }
65 | return nil, &buildkite.Response{
66 | Response: resp,
67 | }, &buildkite.ErrorResponse{Response: resp, Message: "Internal Server Error"}
68 | },
69 | }
70 |
71 | tool, handler, _ := UserTokenOrganization(client)
72 | assert.NotNil(tool)
73 | assert.NotNil(handler)
74 |
75 | request := createMCPRequest(t, map[string]any{})
76 | result, err := handler(ctx, request)
77 | assert.NoError(err)
78 | assert.Contains(getTextResult(t, result).Text, "Internal Server Error")
79 | }
80 |
81 | func TestUserTokenOrganizationErrorNoOrganization(t *testing.T) {
82 | assert := require.New(t)
83 |
84 | ctx := context.Background()
85 | client := &MockOrganizationsClient{
86 | ListFunc: func(ctx context.Context, options *buildkite.OrganizationListOptions) ([]buildkite.Organization, *buildkite.Response, error) {
87 | return nil, &buildkite.Response{
88 | Response: &http.Response{
89 | StatusCode: 200,
90 | },
91 | }, nil
92 | },
93 | }
94 |
95 | tool, handler, _ := UserTokenOrganization(client)
96 | assert.NotNil(tool)
97 | assert.NotNil(handler)
98 |
99 | request := createMCPRequest(t, map[string]any{})
100 | result, err := handler(ctx, request)
101 | assert.NoError(err)
102 |
103 | textContent := getTextResult(t, result)
104 |
105 | assert.Equal("no organization found for the current user token", textContent.Text)
106 | }
107 |
--------------------------------------------------------------------------------
/pkg/buildkite/jobs.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 | "errors"
6 |
7 | "github.com/buildkite/buildkite-mcp-server/pkg/trace"
8 | "github.com/buildkite/go-buildkite/v4"
9 | "github.com/mark3labs/mcp-go/mcp"
10 | "go.opentelemetry.io/otel/attribute"
11 | )
12 |
13 | type JobsClient interface {
14 | UnblockJob(ctx context.Context, org string, pipeline string, buildNumber string, jobID string, opt *buildkite.JobUnblockOptions) (buildkite.Job, *buildkite.Response, error)
15 | }
16 |
17 | // GetJobLogsArgs struct for typed parameters
18 | type GetJobLogsArgs struct {
19 | OrgSlug string `json:"org_slug"`
20 | PipelineSlug string `json:"pipeline_slug"`
21 | BuildNumber string `json:"build_number"`
22 | JobUUID string `json:"job_uuid"`
23 | }
24 |
25 | // UnblockJobArgs struct for typed parameters
26 | type UnblockJobArgs struct {
27 | OrgSlug string `json:"org_slug"`
28 | PipelineSlug string `json:"pipeline_slug"`
29 | BuildNumber string `json:"build_number"`
30 | JobID string `json:"job_id"`
31 | Fields map[string]string `json:"fields,omitempty"`
32 | }
33 |
34 | func UnblockJob(client JobsClient) (tool mcp.Tool, handler mcp.TypedToolHandlerFunc[UnblockJobArgs], scopes []string) {
35 | return mcp.NewTool("unblock_job",
36 | mcp.WithDescription("Unblock a blocked job in a Buildkite build to allow it to continue execution"),
37 | mcp.WithString("org_slug",
38 | mcp.Required(),
39 | ),
40 | mcp.WithString("pipeline_slug",
41 | mcp.Required(),
42 | ),
43 | mcp.WithString("build_number",
44 | mcp.Required(),
45 | ),
46 | mcp.WithString("job_id",
47 | mcp.Required(),
48 | ),
49 | mcp.WithObject("fields",
50 | mcp.Description("JSON object containing string values for block step fields"),
51 | ),
52 | mcp.WithToolAnnotation(mcp.ToolAnnotation{
53 | Title: "Unblock Job",
54 | ReadOnlyHint: mcp.ToBoolPtr(false),
55 | }),
56 | ),
57 | func(ctx context.Context, request mcp.CallToolRequest, args UnblockJobArgs) (*mcp.CallToolResult, error) {
58 | ctx, span := trace.Start(ctx, "buildkite.UnblockJob")
59 | defer span.End()
60 |
61 | // Validate required parameters
62 | if args.OrgSlug == "" {
63 | return mcp.NewToolResultError("org_slug parameter is required"), nil
64 | }
65 | if args.PipelineSlug == "" {
66 | return mcp.NewToolResultError("pipeline_slug parameter is required"), nil
67 | }
68 | if args.BuildNumber == "" {
69 | return mcp.NewToolResultError("build_number parameter is required"), nil
70 | }
71 | if args.JobID == "" {
72 | return mcp.NewToolResultError("job_id parameter is required"), nil
73 | }
74 |
75 | span.SetAttributes(
76 | attribute.String("org_slug", args.OrgSlug),
77 | attribute.String("pipeline_slug", args.PipelineSlug),
78 | attribute.String("build_number", args.BuildNumber),
79 | attribute.String("job_id", args.JobID),
80 | )
81 |
82 | // Prepare unblock options
83 | unblockOptions := buildkite.JobUnblockOptions{}
84 | if len(args.Fields) > 0 {
85 | unblockOptions.Fields = args.Fields
86 | }
87 |
88 | // Unblock the job
89 | job, _, err := client.UnblockJob(ctx, args.OrgSlug, args.PipelineSlug, args.BuildNumber, args.JobID, &unblockOptions)
90 | if err != nil {
91 | var errResp *buildkite.ErrorResponse
92 | if errors.As(err, &errResp) {
93 | if errResp.RawBody != nil {
94 | return mcp.NewToolResultError(string(errResp.RawBody)), nil
95 | }
96 | }
97 |
98 | return mcp.NewToolResultError(err.Error()), nil
99 | }
100 |
101 | return mcpTextResult(span, &job)
102 | }, []string{"write_builds"}
103 | }
104 |
--------------------------------------------------------------------------------
/pkg/buildkite/clusters.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/buildkite/buildkite-mcp-server/pkg/trace"
7 | "github.com/buildkite/go-buildkite/v4"
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/mark3labs/mcp-go/server"
10 | "go.opentelemetry.io/otel/attribute"
11 | )
12 |
13 | type ClustersClient interface {
14 | List(ctx context.Context, org string, opts *buildkite.ClustersListOptions) ([]buildkite.Cluster, *buildkite.Response, error)
15 | Get(ctx context.Context, org, id string) (buildkite.Cluster, *buildkite.Response, error)
16 | }
17 |
18 | func ListClusters(client ClustersClient) (tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) {
19 | return mcp.NewTool("list_clusters",
20 | mcp.WithDescription("List all clusters in an organization with their names, descriptions, default queues, and creation details"),
21 | mcp.WithString("org_slug",
22 | mcp.Required(),
23 | ),
24 | withPagination(),
25 | mcp.WithToolAnnotation(mcp.ToolAnnotation{
26 | Title: "List Clusters",
27 | ReadOnlyHint: mcp.ToBoolPtr(true),
28 | }),
29 | ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
30 | ctx, span := trace.Start(ctx, "buildkite.ListClusters")
31 | defer span.End()
32 |
33 | orgSlug, err := request.RequireString("org_slug")
34 | if err != nil {
35 | return mcp.NewToolResultError(err.Error()), nil
36 | }
37 |
38 | paginationParams, err := optionalPaginationParams(request)
39 | if err != nil {
40 | return mcp.NewToolResultError(err.Error()), nil
41 | }
42 | span.SetAttributes(
43 | attribute.String("org_slug", orgSlug),
44 | attribute.Int("page", paginationParams.Page),
45 | attribute.Int("per_page", paginationParams.PerPage),
46 | )
47 |
48 | clusters, resp, err := client.List(ctx, orgSlug, &buildkite.ClustersListOptions{
49 | ListOptions: paginationParams,
50 | })
51 | if err != nil {
52 | return mcp.NewToolResultError(err.Error()), nil
53 | }
54 |
55 | if len(clusters) == 0 {
56 | return mcp.NewToolResultText("No clusters found"), nil
57 | }
58 |
59 | result := PaginatedResult[buildkite.Cluster]{
60 | Items: clusters,
61 | Headers: map[string]string{
62 | "Link": resp.Header.Get("Link"),
63 | },
64 | }
65 |
66 | span.SetAttributes(
67 | attribute.Int("item_count", len(clusters)),
68 | )
69 |
70 | return mcpTextResult(span, &result)
71 | }, []string{"read_clusters"}
72 | }
73 |
74 | func GetCluster(client ClustersClient) (tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) {
75 | return mcp.NewTool("get_cluster",
76 | mcp.WithDescription("Get detailed information about a specific cluster including its name, description, default queue, and configuration"),
77 | mcp.WithString("org_slug",
78 | mcp.Required(),
79 | ),
80 | mcp.WithString("cluster_id",
81 | mcp.Required(),
82 | ),
83 | mcp.WithToolAnnotation(mcp.ToolAnnotation{
84 | Title: "Get Cluster",
85 | ReadOnlyHint: mcp.ToBoolPtr(true),
86 | }),
87 | ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
88 | ctx, span := trace.Start(ctx, "buildkite.GetCluster")
89 | defer span.End()
90 |
91 | orgSlug, err := request.RequireString("org_slug")
92 | if err != nil {
93 | return mcp.NewToolResultError(err.Error()), nil
94 | }
95 |
96 | clusterID, err := request.RequireString("cluster_id")
97 | if err != nil {
98 | return mcp.NewToolResultError(err.Error()), nil
99 | }
100 | span.SetAttributes(
101 | attribute.String("org_slug", orgSlug),
102 | attribute.String("cluster_id", clusterID),
103 | )
104 |
105 | cluster, _, err := client.Get(ctx, orgSlug, clusterID)
106 | if err != nil {
107 | return mcp.NewToolResultError(err.Error()), nil
108 | }
109 |
110 | return mcpTextResult(span, &cluster)
111 | }, []string{"read_clusters"}
112 | }
113 |
--------------------------------------------------------------------------------
/pkg/server/mcp.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | buildkitelogs "github.com/buildkite/buildkite-logs"
5 | "github.com/buildkite/buildkite-mcp-server/pkg/buildkite"
6 | "github.com/buildkite/buildkite-mcp-server/pkg/toolsets"
7 | "github.com/buildkite/buildkite-mcp-server/pkg/trace"
8 | gobuildkite "github.com/buildkite/go-buildkite/v4"
9 | "github.com/mark3labs/mcp-go/mcp"
10 | "github.com/mark3labs/mcp-go/server"
11 | "github.com/rs/zerolog/log"
12 | )
13 |
14 | // ToolsetOption configures toolset behavior
15 | type ToolsetOption func(*ToolsetConfig)
16 |
17 | // ToolsetConfig holds configuration for toolset selection and behavior
18 | type ToolsetConfig struct {
19 | EnabledToolsets []string
20 | ReadOnly bool
21 | }
22 |
23 | // WithToolsets enables specific toolsets
24 | func WithToolsets(toolsets ...string) ToolsetOption {
25 | return func(cfg *ToolsetConfig) {
26 | cfg.EnabledToolsets = toolsets
27 | }
28 | }
29 |
30 | // WithReadOnly enables read-only mode which filters out write operations
31 | func WithReadOnly(readOnly bool) ToolsetOption {
32 | return func(cfg *ToolsetConfig) {
33 | cfg.ReadOnly = readOnly
34 | }
35 | }
36 |
37 | // NewMCPServer creates a new MCP server with the given configuration and toolsets
38 | func NewMCPServer(version string, client *gobuildkite.Client, buildkiteLogsClient *buildkitelogs.Client, opts ...ToolsetOption) *server.MCPServer {
39 | // Default configuration
40 | cfg := &ToolsetConfig{
41 | EnabledToolsets: []string{"all"},
42 | ReadOnly: false,
43 | }
44 |
45 | // Apply options
46 | for _, opt := range opts {
47 | opt(cfg)
48 | }
49 |
50 | s := server.NewMCPServer(
51 | "buildkite-mcp-server",
52 | version,
53 | server.WithToolCapabilities(true),
54 | server.WithPromptCapabilities(true),
55 | server.WithResourceCapabilities(true, true),
56 | server.WithToolHandlerMiddleware(trace.ToolHandlerFunc),
57 | server.WithResourceHandlerMiddleware(trace.WithResourceHandlerFunc),
58 | server.WithHooks(trace.NewHooks()),
59 | server.WithLogging())
60 |
61 | log.Info().Str("version", version).Msg("Starting Buildkite MCP server")
62 |
63 | // Use toolset system with configuration
64 | s.AddTools(BuildkiteTools(client, buildkiteLogsClient, WithReadOnly(cfg.ReadOnly), WithToolsets(cfg.EnabledToolsets...))...)
65 |
66 | s.AddPrompt(mcp.NewPrompt("user_token_organization_prompt",
67 | mcp.WithPromptDescription("When asked for detail of a users pipelines start by looking up the user's token organization"),
68 | ), buildkite.HandleUserTokenOrganizationPrompt)
69 |
70 | s.AddResource(mcp.NewResource(
71 | "debug-logs-guide",
72 | "Debug Logs Guide",
73 | mcp.WithResourceDescription("Comprehensive guide for debugging Buildkite build failures using logs"),
74 | ), buildkite.HandleDebugLogsGuideResource)
75 |
76 | return s
77 | }
78 |
79 | // BuildkiteTools creates tools using the toolset system with functional options
80 | func BuildkiteTools(client *gobuildkite.Client, buildkiteLogsClient *buildkitelogs.Client, opts ...ToolsetOption) []server.ServerTool {
81 | cfg := &ToolsetConfig{
82 | EnabledToolsets: []string{"all"},
83 | ReadOnly: false,
84 | }
85 |
86 | for _, opt := range opts {
87 | opt(cfg)
88 | }
89 |
90 | registry := toolsets.NewToolsetRegistry()
91 |
92 | registry.RegisterToolsets(
93 | toolsets.CreateBuiltinToolsets(client, buildkiteLogsClient),
94 | )
95 |
96 | enabledTools := registry.GetEnabledTools(cfg.EnabledToolsets, cfg.ReadOnly)
97 |
98 | var serverTools []server.ServerTool
99 | for _, toolDef := range enabledTools {
100 | serverTools = append(serverTools, server.ServerTool{
101 | Tool: toolDef.Tool,
102 | Handler: toolDef.Handler,
103 | })
104 | }
105 |
106 | scopes := registry.GetRequiredScopes(cfg.EnabledToolsets, cfg.ReadOnly)
107 |
108 | log.Info().
109 | Strs("enabled_toolsets", cfg.EnabledToolsets).
110 | Bool("read_only", cfg.ReadOnly).
111 | Int("tool_count", len(serverTools)).
112 | Strs("required_scopes", scopes).
113 | Msg("Registered tools from toolsets")
114 |
115 | return serverTools
116 | }
117 |
--------------------------------------------------------------------------------
/pkg/buildkite/buildkite.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 |
7 | "github.com/buildkite/buildkite-mcp-server/pkg/tokens"
8 | "github.com/buildkite/go-buildkite/v4"
9 | "github.com/mark3labs/mcp-go/mcp"
10 | "go.opentelemetry.io/otel/attribute"
11 | "go.opentelemetry.io/otel/trace"
12 | )
13 |
14 | type PaginatedResult[T any] struct {
15 | Headers map[string]string `json:"headers"`
16 | Items []T `json:"items"`
17 | }
18 |
19 | func optionalPaginationParams(r mcp.CallToolRequest) (buildkite.ListOptions, error) {
20 | page := r.GetInt("page", 1)
21 | perPage := r.GetInt("perPage", 100)
22 | return buildkite.ListOptions{
23 | Page: page,
24 | PerPage: perPage,
25 | }, nil
26 | }
27 |
28 | func withPagination() mcp.ToolOption {
29 | return func(tool *mcp.Tool) {
30 | mcp.WithNumber("page",
31 | mcp.Description("Page number for pagination (min 1)"),
32 | mcp.Min(1),
33 | )(tool)
34 |
35 | mcp.WithNumber("perPage",
36 | mcp.Description("Results per page for pagination (min 1, max 100)"),
37 | mcp.Min(1),
38 | mcp.Max(100),
39 | )(tool)
40 | }
41 | }
42 |
43 | // ClientSidePaginationParams represents parameters for client-side pagination
44 | type ClientSidePaginationParams struct {
45 | Page int
46 | PerPage int
47 | }
48 |
49 | // ClientSidePaginatedResult represents a paginated result for client-side pagination
50 | type ClientSidePaginatedResult[T any] struct {
51 | Items []T `json:"items"`
52 | Page int `json:"page"`
53 | PerPage int `json:"per_page"`
54 | Total int `json:"total"`
55 | TotalPages int `json:"total_pages"`
56 | HasNext bool `json:"has_next"`
57 | HasPrev bool `json:"has_prev"`
58 | }
59 |
60 | // withClientSidePagination adds client-side pagination options to a tool
61 | func withClientSidePagination() mcp.ToolOption {
62 | return func(tool *mcp.Tool) {
63 | mcp.WithNumber("page",
64 | mcp.Description("Page number for pagination (min 1)"),
65 | mcp.Min(1),
66 | )(tool)
67 |
68 | mcp.WithNumber("perPage",
69 | mcp.Description("Results per page for pagination (min 1, max 100)"),
70 | mcp.Min(1),
71 | mcp.Max(100),
72 | )(tool)
73 | }
74 | }
75 |
76 | // getClientSidePaginationParams extracts client-side pagination parameters from request
77 | // Always returns pagination params with sensible defaults
78 | func getClientSidePaginationParams(r mcp.CallToolRequest) ClientSidePaginationParams {
79 | page := r.GetInt("page", 1)
80 | perPage := r.GetInt("perPage", 25) // Default page size for client-side pagination
81 |
82 | return ClientSidePaginationParams{
83 | Page: page,
84 | PerPage: perPage,
85 | }
86 | }
87 |
88 | // applyClientSidePagination applies client-side pagination to a slice of items
89 | func applyClientSidePagination[T any](items []T, params ClientSidePaginationParams) ClientSidePaginatedResult[T] {
90 | total := len(items)
91 | totalPages := (total + params.PerPage - 1) / params.PerPage
92 | if totalPages == 0 {
93 | totalPages = 1
94 | }
95 |
96 | startIndex := (params.Page - 1) * params.PerPage
97 | endIndex := startIndex + params.PerPage
98 |
99 | var paginatedItems []T
100 | if startIndex >= total {
101 | paginatedItems = []T{}
102 | } else {
103 | if endIndex > total {
104 | endIndex = total
105 | }
106 | paginatedItems = items[startIndex:endIndex]
107 | }
108 |
109 | return ClientSidePaginatedResult[T]{
110 | Items: paginatedItems,
111 | Page: params.Page,
112 | PerPage: params.PerPage,
113 | Total: total,
114 | TotalPages: totalPages,
115 | HasNext: params.Page < totalPages,
116 | HasPrev: params.Page > 1,
117 | }
118 | }
119 |
120 | func mcpTextResult(span trace.Span, result any) (*mcp.CallToolResult, error) {
121 | r, err := json.Marshal(result)
122 | if err != nil {
123 | return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil
124 | }
125 |
126 | span.SetAttributes(
127 | attribute.Int("estimated_tokens", tokens.EstimateTokens(string(r))),
128 | )
129 |
130 | return mcp.NewToolResultText(string(r)), nil
131 | }
132 |
--------------------------------------------------------------------------------
/DEVELOPMENT.md:
--------------------------------------------------------------------------------
1 | # Development
2 |
3 | This contains some notes on developing this software locally.
4 |
5 | # prerequisites
6 |
7 | * [goreleaser](http://goreleaser.com)
8 | * [go 1.24](https://go.dev)
9 |
10 | # building
11 |
12 | List the available make targets.
13 |
14 | ```
15 | make help
16 | ```
17 |
18 | ## Local Build
19 |
20 | Build the binary locally.
21 |
22 | ```bash
23 | make build
24 | ```
25 |
26 | ## Check the code
27 |
28 | Check the code for style and correctness and running tests.
29 |
30 | ```bash
31 | make check
32 | ```
33 |
34 | ## Copy it to your path
35 |
36 | Copy it to your path.
37 |
38 | ## Docker
39 |
40 | ### Local Development
41 |
42 | Build the Docker image using the local development Dockerfile:
43 |
44 | ```bash
45 | docker build -t buildkite/buildkite-mcp-server:dev -f Dockerfile.local .
46 | ```
47 |
48 | Run the container:
49 |
50 | ```bash
51 | docker run -i --rm -e BUILDKITE_API_TOKEN="your-token" buildkite/buildkite-mcp-server:dev
52 | ```
53 |
54 | # Adding a new Tool
55 |
56 | 1. Implement a tool following the patterns in the [internal/buildkite](internal/buildkite) package - mostly delegating to [go-buildkite](https://github.com/buildkite/go-buildkite) and returning JSON. We can play with nicer formatting later and see if it helps.
57 | 2. Register the tool here in the [internal/stdio](internal/commands/stdio.go) file.
58 | 3. Update the README tool list.
59 | 4. Profit!
60 |
61 | # Validating tools locally
62 |
63 | When developing and testing the tools, and verifying their configuration https://github.com/modelcontextprotocol/inspector is very helpful.
64 |
65 | ```
66 | make
67 | npx @modelcontextprotocol/inspector@latest buildkite-mcp-server stdio
68 | ```
69 |
70 | Then log into the web UI and hit connect.
71 |
72 | # Publishing a release
73 |
74 | - Draft a new release on GitHub: https://github.com/buildkite/buildkite-mcp-server/releases/new
75 | - Select a new tag version, bumping the minor or patch versions as appropriate. This project is pre-1.0, so we don't make strong compatibility guarantees.
76 | - Generate release notes
77 | - Save the release as a draft, and mention internal contributors on Slack before publishing
78 | - Publish the release
79 |
80 | A Buildkite pipeline will then automatically invoke the publishing pipeline, including publishing to GitHub Container Registry, Docker Hub, and update binaries to the GitHub release assets.
81 |
82 | # Manually releasing to GitHub Container Registry
83 |
84 | This process is automated by the CI pipeline, however you can manually release by following these steps:
85 |
86 | To push docker images GHCR you will need to login, you will need to generate a legacy GitHub PSK to do a release locally. This will be entered in the command below.
87 |
88 | ```
89 | docker login ghcr.io --username $(gh api user --jq '.login')
90 | ```
91 |
92 | Publish a release in GitHub, use the "generate changelog" button to build the changelog, this will create a tag for the release.
93 |
94 | Fetch tags and pull down the `main` branch, then run GoReleaser at the root of the repository.
95 |
96 | ```
97 | git fetch && git pull
98 | GITHUB_TOKEN=$(gh auth token) goreleaser release
99 | ```
100 |
101 | # Tracing
102 |
103 | To enable tracing in the MCP server you need to add some environment variables in the configuration, the example below is showing the claude desktop configuration paired with [honeycomb](https://honeycomb.io), however any OTEL service will work as long as it supports GRPC.
104 |
105 | ```json
106 | {
107 | "mcpServers": {
108 | "buildkite": {
109 | "command": "buildkite-mcp-server",
110 | "args": [
111 | "stdio"
112 | ],
113 | "env": {
114 | "BUILDKITE_API_TOKEN": "bkua_xxxxx",
115 | "OTEL_SERVICE_NAME": "buildkite-mcp-server",
116 | "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
117 | "OTEL_EXPORTER_OTLP_ENDPOINT": "https://api.honeycomb.io:443",
118 | "OTEL_EXPORTER_OTLP_HEADERS":"x-honeycomb-team=xxxxxx"
119 | }
120 | }
121 | }
122 | }
123 | ```
124 |
--------------------------------------------------------------------------------
/pkg/buildkite/cluster_queue.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/buildkite/buildkite-mcp-server/pkg/trace"
7 | "github.com/buildkite/go-buildkite/v4"
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/mark3labs/mcp-go/server"
10 | "go.opentelemetry.io/otel/attribute"
11 | )
12 |
13 | type ClusterQueuesClient interface {
14 | List(ctx context.Context, org, clusterID string, opts *buildkite.ClusterQueuesListOptions) ([]buildkite.ClusterQueue, *buildkite.Response, error)
15 | Get(ctx context.Context, org, clusterID, queueID string) (buildkite.ClusterQueue, *buildkite.Response, error)
16 | }
17 |
18 | func ListClusterQueues(client ClusterQueuesClient) (tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) {
19 | return mcp.NewTool("list_cluster_queues",
20 | mcp.WithDescription("List all queues in a cluster with their keys, descriptions, dispatch status, and agent configuration"),
21 | mcp.WithString("org_slug",
22 | mcp.Required(),
23 | ),
24 | mcp.WithString("cluster_id",
25 | mcp.Required(),
26 | ),
27 | withPagination(),
28 | mcp.WithToolAnnotation(mcp.ToolAnnotation{
29 | Title: "List Cluster Queues",
30 | ReadOnlyHint: mcp.ToBoolPtr(true),
31 | }),
32 | ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
33 | ctx, span := trace.Start(ctx, "buildkite.ListClusterQueues")
34 | defer span.End()
35 |
36 | orgSlug, err := request.RequireString("org_slug")
37 | if err != nil {
38 | return mcp.NewToolResultError(err.Error()), nil
39 | }
40 |
41 | clusterID, err := request.RequireString("cluster_id")
42 | if err != nil {
43 | return mcp.NewToolResultError(err.Error()), nil
44 | }
45 |
46 | paginationParams, err := optionalPaginationParams(request)
47 | if err != nil {
48 | return mcp.NewToolResultError(err.Error()), nil
49 | }
50 | span.SetAttributes(
51 | attribute.String("org_slug", orgSlug),
52 | attribute.String("cluster_id", clusterID),
53 | attribute.Int("page", paginationParams.Page),
54 | attribute.Int("per_page", paginationParams.PerPage),
55 | )
56 |
57 | queues, resp, err := client.List(ctx, orgSlug, clusterID, &buildkite.ClusterQueuesListOptions{
58 | ListOptions: paginationParams,
59 | })
60 | if err != nil {
61 | return mcp.NewToolResultError(err.Error()), nil
62 | }
63 |
64 | if len(queues) == 0 {
65 | return mcp.NewToolResultText("No clusters found"), nil
66 | }
67 |
68 | result := PaginatedResult[buildkite.ClusterQueue]{
69 | Items: queues,
70 | Headers: map[string]string{
71 | "Link": resp.Header.Get("Link"),
72 | },
73 | }
74 |
75 | span.SetAttributes(
76 | attribute.Int("item_count", len(queues)),
77 | )
78 |
79 | return mcpTextResult(span, &result)
80 | }, []string{"read_clusters"}
81 | }
82 |
83 | func GetClusterQueue(client ClusterQueuesClient) (tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) {
84 | return mcp.NewTool("get_cluster_queue",
85 | mcp.WithDescription("Get detailed information about a specific queue including its key, description, dispatch status, and hosted agent configuration"),
86 | mcp.WithString("org_slug",
87 | mcp.Required(),
88 | ),
89 | mcp.WithString("cluster_id",
90 | mcp.Required(),
91 | ),
92 | mcp.WithString("queue_id",
93 | mcp.Required(),
94 | ),
95 | mcp.WithToolAnnotation(mcp.ToolAnnotation{
96 | Title: "Get Cluster Queue",
97 | ReadOnlyHint: mcp.ToBoolPtr(true),
98 | }),
99 | ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
100 | ctx, span := trace.Start(ctx, "buildkite.GetClusterQueue")
101 | defer span.End()
102 |
103 | orgSlug, err := request.RequireString("org_slug")
104 | if err != nil {
105 | return mcp.NewToolResultError(err.Error()), nil
106 | }
107 |
108 | clusterID, err := request.RequireString("cluster_id")
109 | if err != nil {
110 | return mcp.NewToolResultError(err.Error()), nil
111 | }
112 |
113 | queueID, err := request.RequireString("queue_id")
114 | if err != nil {
115 | return mcp.NewToolResultError(err.Error()), nil
116 | }
117 | span.SetAttributes(
118 | attribute.String("org_slug", orgSlug),
119 | attribute.String("cluster_id", clusterID),
120 | attribute.String("queue_id", queueID),
121 | )
122 |
123 | queue, _, err := client.Get(ctx, orgSlug, clusterID, queueID)
124 | if err != nil {
125 | return mcp.NewToolResultError(err.Error()), nil
126 | }
127 |
128 | return mcpTextResult(span, &queue)
129 | }, []string{"read_clusters"}
130 | }
131 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/buildkite/buildkite-mcp-server
2 |
3 | go 1.24.5
4 |
5 | require (
6 | github.com/alecthomas/kong v1.13.0
7 | github.com/buildkite/buildkite-logs v0.6.3
8 | github.com/buildkite/go-buildkite/v4 v4.13.1
9 | github.com/cenkalti/backoff/v5 v5.0.3
10 | github.com/mark3labs/mcp-go v0.43.2
11 | github.com/mattn/go-isatty v0.0.20
12 | github.com/rs/zerolog v1.34.0
13 | github.com/stretchr/testify v1.11.1
14 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0
15 | go.opentelemetry.io/otel v1.39.0
16 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0
17 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0
18 | go.opentelemetry.io/otel/sdk v1.39.0
19 | go.opentelemetry.io/otel/trace v1.39.0
20 | )
21 |
22 | require (
23 | github.com/andybalholm/brotli v1.2.0 // indirect
24 | github.com/apache/arrow-go/v18 v18.4.0 // indirect
25 | github.com/apache/thrift v0.22.0 // indirect
26 | github.com/aws/aws-sdk-go v1.55.8 // indirect
27 | github.com/aws/aws-sdk-go-v2 v1.38.1 // indirect
28 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect
29 | github.com/aws/aws-sdk-go-v2/config v1.31.2 // indirect
30 | github.com/aws/aws-sdk-go-v2/credentials v1.18.6 // indirect
31 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4 // indirect
32 | github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.0 // indirect
33 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4 // indirect
34 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4 // indirect
35 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
36 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.4 // indirect
37 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 // indirect
38 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.4 // indirect
39 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4 // indirect
40 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.4 // indirect
41 | github.com/aws/aws-sdk-go-v2/service/s3 v1.87.1 // indirect
42 | github.com/aws/aws-sdk-go-v2/service/sso v1.28.2 // indirect
43 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.2 // indirect
44 | github.com/aws/aws-sdk-go-v2/service/sts v1.38.0 // indirect
45 | github.com/aws/smithy-go v1.22.5 // indirect
46 | github.com/bahlo/generic-list-go v0.2.0 // indirect
47 | github.com/buger/jsonparser v1.1.1 // indirect
48 | github.com/cenkalti/backoff v2.2.1+incompatible // indirect
49 | github.com/cespare/xxhash/v2 v2.3.0 // indirect
50 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
51 | github.com/felixge/httpsnoop v1.0.4 // indirect
52 | github.com/go-logr/logr v1.4.3 // indirect
53 | github.com/go-logr/stdr v1.2.2 // indirect
54 | github.com/goccy/go-json v0.10.5 // indirect
55 | github.com/golang/snappy v1.0.0 // indirect
56 | github.com/google/flatbuffers v25.2.10+incompatible // indirect
57 | github.com/google/go-querystring v1.1.0 // indirect
58 | github.com/google/uuid v1.6.0 // indirect
59 | github.com/google/wire v0.7.0 // indirect
60 | github.com/googleapis/gax-go/v2 v2.15.0 // indirect
61 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
62 | github.com/invopop/jsonschema v0.13.0 // indirect
63 | github.com/jmespath/go-jmespath v0.4.0 // indirect
64 | github.com/klauspost/asmfmt v1.3.2 // indirect
65 | github.com/klauspost/compress v1.18.0 // indirect
66 | github.com/klauspost/cpuid/v2 v2.3.0 // indirect
67 | github.com/mailru/easyjson v0.7.7 // indirect
68 | github.com/mattn/go-colorable v0.1.14 // indirect
69 | github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 // indirect
70 | github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 // indirect
71 | github.com/pierrec/lz4/v4 v4.1.22 // indirect
72 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
73 | github.com/spf13/cast v1.8.0 // indirect
74 | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
75 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
76 | github.com/zeebo/xxh3 v1.0.2 // indirect
77 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect
78 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
79 | go.opentelemetry.io/otel/metric v1.39.0 // indirect
80 | go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
81 | go.opentelemetry.io/proto/otlp v1.9.0 // indirect
82 | gocloud.dev v0.43.0 // indirect
83 | golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect
84 | golang.org/x/mod v0.29.0 // indirect
85 | golang.org/x/net v0.47.0 // indirect
86 | golang.org/x/sync v0.18.0 // indirect
87 | golang.org/x/sys v0.39.0 // indirect
88 | golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect
89 | golang.org/x/text v0.31.0 // indirect
90 | golang.org/x/tools v0.38.0 // indirect
91 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
92 | google.golang.org/api v0.248.0 // indirect
93 | google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
94 | google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
95 | google.golang.org/grpc v1.77.0 // indirect
96 | google.golang.org/protobuf v1.36.10 // indirect
97 | gopkg.in/yaml.v3 v3.0.1 // indirect
98 | )
99 |
--------------------------------------------------------------------------------
/pkg/buildkite/test_runs.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "io"
8 | "net/http"
9 |
10 | "github.com/buildkite/buildkite-mcp-server/pkg/tokens"
11 | "github.com/buildkite/buildkite-mcp-server/pkg/trace"
12 | "github.com/buildkite/go-buildkite/v4"
13 | "github.com/mark3labs/mcp-go/mcp"
14 | "github.com/mark3labs/mcp-go/server"
15 | "go.opentelemetry.io/otel/attribute"
16 | )
17 |
18 | type TestRunsClient interface {
19 | Get(ctx context.Context, org, slug, runID string) (buildkite.TestRun, *buildkite.Response, error)
20 | List(ctx context.Context, org, slug string, opt *buildkite.TestRunsListOptions) ([]buildkite.TestRun, *buildkite.Response, error)
21 | GetFailedExecutions(ctx context.Context, org, slug, runID string, opt *buildkite.FailedExecutionsOptions) ([]buildkite.FailedExecution, *buildkite.Response, error)
22 | }
23 |
24 | func ListTestRuns(client TestRunsClient) (tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) {
25 | return mcp.NewTool("list_test_runs",
26 | mcp.WithDescription("List all test runs for a test suite in Buildkite Test Engine"),
27 | mcp.WithString("org_slug",
28 | mcp.Required(),
29 | ),
30 | mcp.WithString("test_suite_slug",
31 | mcp.Required(),
32 | ),
33 | withPagination(),
34 | mcp.WithToolAnnotation(mcp.ToolAnnotation{
35 | Title: "List Test Runs",
36 | ReadOnlyHint: mcp.ToBoolPtr(true),
37 | }),
38 | ),
39 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
40 | ctx, span := trace.Start(ctx, "buildkite.ListTestRuns")
41 | defer span.End()
42 |
43 | orgSlug, err := request.RequireString("org_slug")
44 | if err != nil {
45 | return mcp.NewToolResultError(err.Error()), nil
46 | }
47 |
48 | testSuiteSlug, err := request.RequireString("test_suite_slug")
49 | if err != nil {
50 | return mcp.NewToolResultError(err.Error()), nil
51 | }
52 |
53 | paginationParams, err := optionalPaginationParams(request)
54 | if err != nil {
55 | return mcp.NewToolResultError(err.Error()), nil
56 | }
57 |
58 | span.SetAttributes(
59 | attribute.String("org_slug", orgSlug),
60 | attribute.String("test_suite_slug", testSuiteSlug),
61 | attribute.Int("page", paginationParams.Page),
62 | attribute.Int("per_page", paginationParams.PerPage),
63 | )
64 |
65 | options := &buildkite.TestRunsListOptions{
66 | ListOptions: paginationParams,
67 | }
68 |
69 | testRuns, resp, err := client.List(ctx, orgSlug, testSuiteSlug, options)
70 | if err != nil {
71 | return mcp.NewToolResultError(err.Error()), nil
72 | }
73 |
74 | result := PaginatedResult[buildkite.TestRun]{
75 | Items: testRuns,
76 | Headers: map[string]string{
77 | "Link": resp.Header.Get("Link"),
78 | },
79 | }
80 |
81 | r, err := json.Marshal(&result)
82 | if err != nil {
83 | return nil, fmt.Errorf("failed to marshal test runs: %w", err)
84 | }
85 |
86 | span.SetAttributes(
87 | attribute.Int("item_count", len(testRuns)),
88 | attribute.Int("estimated_tokens", tokens.EstimateTokens(string(r))),
89 | )
90 |
91 | return mcp.NewToolResultText(string(r)), nil
92 | }, []string{"read_suites"}
93 | }
94 |
95 | func GetTestRun(client TestRunsClient) (tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) {
96 | return mcp.NewTool("get_test_run",
97 | mcp.WithDescription("Get a specific test run in Buildkite Test Engine"),
98 | mcp.WithString("org_slug",
99 | mcp.Required(),
100 | ),
101 | mcp.WithString("test_suite_slug",
102 | mcp.Required(),
103 | ),
104 | mcp.WithString("run_id",
105 | mcp.Required(),
106 | ),
107 | mcp.WithToolAnnotation(mcp.ToolAnnotation{
108 | Title: "Get Test Run",
109 | ReadOnlyHint: mcp.ToBoolPtr(true),
110 | }),
111 | ),
112 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
113 | ctx, span := trace.Start(ctx, "buildkite.GetTestRun")
114 | defer span.End()
115 |
116 | orgSlug, err := request.RequireString("org_slug")
117 | if err != nil {
118 | return mcp.NewToolResultError(err.Error()), nil
119 | }
120 |
121 | testSuiteSlug, err := request.RequireString("test_suite_slug")
122 | if err != nil {
123 | return mcp.NewToolResultError(err.Error()), nil
124 | }
125 |
126 | runID, err := request.RequireString("run_id")
127 | if err != nil {
128 | return mcp.NewToolResultError(err.Error()), nil
129 | }
130 |
131 | span.SetAttributes(
132 | attribute.String("org_slug", orgSlug),
133 | attribute.String("test_suite_slug", testSuiteSlug),
134 | attribute.String("run_id", runID),
135 | )
136 |
137 | testRun, resp, err := client.Get(ctx, orgSlug, testSuiteSlug, runID)
138 | if err != nil {
139 | return mcp.NewToolResultError(err.Error()), nil
140 | }
141 |
142 | if resp.StatusCode != http.StatusOK {
143 | body, err := io.ReadAll(resp.Body)
144 | if err != nil {
145 | return nil, fmt.Errorf("failed to read response body: %w", err)
146 | }
147 | return mcp.NewToolResultError(fmt.Sprintf("failed to get test run: %s", string(body))), nil
148 | }
149 |
150 | return mcpTextResult(span, &testRun)
151 | }, []string{"read_suites"}
152 | }
153 |
--------------------------------------------------------------------------------
/cmd/buildkite-mcp-server/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "os"
7 | "time"
8 |
9 | "github.com/alecthomas/kong"
10 | buildkitelogs "github.com/buildkite/buildkite-logs"
11 | "github.com/buildkite/buildkite-mcp-server/internal/commands"
12 | "github.com/buildkite/buildkite-mcp-server/pkg/trace"
13 | gobuildkite "github.com/buildkite/go-buildkite/v4"
14 | "github.com/mattn/go-isatty"
15 | "github.com/rs/zerolog"
16 | "github.com/rs/zerolog/log"
17 | )
18 |
19 | var (
20 | version = "dev"
21 |
22 | cli struct {
23 | Stdio commands.StdioCmd `cmd:"" help:"stdio mcp server."`
24 | HTTP commands.HTTPCmd `cmd:"" help:"http mcp server. (pass --use-sse to use SSE transport"`
25 | Tools commands.ToolsCmd `cmd:"" help:"list available tools." hidden:""`
26 | APIToken string `help:"The Buildkite API token to use." env:"BUILDKITE_API_TOKEN"`
27 | APITokenFrom1Password string `help:"The 1Password item to read the Buildkite API token from. Format: 'op://vault/item/field'" env:"BUILDKITE_API_TOKEN_FROM_1PASSWORD"`
28 | BaseURL string `help:"The base URL of the Buildkite API to use." env:"BUILDKITE_BASE_URL" default:"https://api.buildkite.com/"`
29 | CacheURL string `help:"The blob storage URL for job logs cache." env:"BKLOG_CACHE_URL"`
30 | Debug bool `help:"Enable debug mode." env:"DEBUG"`
31 | OTELExporter string `help:"OpenTelemetry exporter to enable. Options are 'http/protobuf', 'grpc', or 'noop'." enum:"http/protobuf, grpc, noop" env:"OTEL_EXPORTER_OTLP_PROTOCOL" default:"noop"`
32 | HTTPHeaders []string `help:"Additional HTTP headers to send with every request. Format: 'Key: Value'" name:"http-header" env:"BUILDKITE_HTTP_HEADERS"`
33 | Version kong.VersionFlag
34 | }
35 | )
36 |
37 | func main() {
38 | ctx := context.Background()
39 |
40 | cmd := kong.Parse(&cli,
41 | kong.Name("buildkite-mcp-server"),
42 | kong.Description("A server that proxies requests to the Buildkite API."),
43 | kong.UsageOnError(),
44 | kong.Vars{
45 | "version": version,
46 | },
47 | kong.BindTo(ctx, (*context.Context)(nil)),
48 | )
49 |
50 | log.Logger = setupLogger(cli.Debug)
51 |
52 | err := run(ctx, cmd)
53 | cmd.FatalIfErrorf(err)
54 | }
55 |
56 | func run(ctx context.Context, cmd *kong.Context) error {
57 | tp, err := trace.NewProvider(ctx, cli.OTELExporter, "buildkite-mcp-server", version)
58 | if err != nil {
59 | return fmt.Errorf("failed to create trace provider: %w", err)
60 | }
61 | defer func() {
62 | _ = tp.Shutdown(ctx)
63 | }()
64 |
65 | // Parse additional headers into a map
66 | headers := commands.ParseHeaders(cli.HTTPHeaders)
67 |
68 | // resolve the api token from either the token or 1password flag
69 | apiToken, err := commands.ResolveAPIToken(cli.APIToken, cli.APITokenFrom1Password)
70 | if err != nil {
71 | return fmt.Errorf("failed to resolve Buildkite API token: %w", err)
72 | }
73 |
74 | client, err := gobuildkite.NewOpts(
75 | gobuildkite.WithTokenAuth(apiToken),
76 | gobuildkite.WithUserAgent(commands.UserAgent(version)),
77 | gobuildkite.WithHTTPClient(trace.NewHTTPClientWithHeaders(headers)),
78 | gobuildkite.WithBaseURL(cli.BaseURL),
79 | )
80 | if err != nil {
81 | return fmt.Errorf("failed to create buildkite client: %w", err)
82 | }
83 |
84 | // Create ParquetClient with cache URL from flag/env (uses upstream library's high-level client)
85 | buildkiteLogsClient, err := buildkitelogs.NewClient(ctx, client, cli.CacheURL)
86 | if err != nil {
87 | return fmt.Errorf("failed to create buildkite logs client: %w", err)
88 | }
89 |
90 | buildkiteLogsClient.Hooks().AddAfterCacheCheck(func(ctx context.Context, result *buildkitelogs.CacheCheckResult) {
91 | log.Ctx(ctx).Debug().Str("org", result.Org).Str("pipeline", result.Pipeline).Str("build", result.Build).Str("job", result.Job).Dur("time_taken", result.Duration).Msg("Checked job logs cache")
92 | })
93 |
94 | buildkiteLogsClient.Hooks().AddAfterLogDownload(func(ctx context.Context, result *buildkitelogs.LogDownloadResult) {
95 | log.Ctx(ctx).Debug().Str("org", result.Org).Str("pipeline", result.Pipeline).Str("build", result.Build).Str("job", result.Job).Dur("time_taken", result.Duration).Msg("Downloaded and cached job logs")
96 | })
97 |
98 | buildkiteLogsClient.Hooks().AddAfterLogParsing(func(ctx context.Context, result *buildkitelogs.LogParsingResult) {
99 | log.Ctx(ctx).Debug().Str("org", result.Org).Str("pipeline", result.Pipeline).Str("build", result.Build).Str("job", result.Job).Dur("time_taken", result.Duration).Msg("Parsed logs to Parquet")
100 | })
101 |
102 | buildkiteLogsClient.Hooks().AddAfterBlobStorage(func(ctx context.Context, result *buildkitelogs.BlobStorageResult) {
103 | log.Ctx(ctx).Debug().Str("org", result.Org).Str("pipeline", result.Pipeline).Str("build", result.Build).Str("job", result.Job).Dur("time_taken", result.Duration).Msg("Stored logs to blob storage")
104 | })
105 |
106 | return cmd.Run(&commands.Globals{Version: version, Client: client, BuildkiteLogsClient: buildkiteLogsClient})
107 | }
108 |
109 | func setupLogger(debug bool) zerolog.Logger {
110 | var logger zerolog.Logger
111 | level := zerolog.InfoLevel
112 | if debug {
113 | level = zerolog.DebugLevel
114 | }
115 |
116 | logger = zerolog.New(os.Stderr).Level(level).With().Timestamp().Stack().Logger()
117 |
118 | // are we in an interactive terminal use a console writer
119 | if isatty.IsTerminal(os.Stdout.Fd()) {
120 | logger = logger.Output(zerolog.ConsoleWriter{Out: os.Stderr, FormatTimestamp: func(i any) string {
121 | return time.Now().Format(time.Stamp)
122 | }}).Level(level).With().Stack().Logger()
123 | }
124 |
125 | return logger
126 | }
127 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 |
2 | # Contributor Covenant Code of Conduct
3 |
4 | ## Our Pledge
5 |
6 | We as members, contributors, and leaders pledge to make participation in our
7 | community a harassment-free experience for everyone, regardless of age, body
8 | size, visible or invisible disability, ethnicity, sex characteristics, gender
9 | identity and expression, level of experience, education, socio-economic status,
10 | nationality, personal appearance, race, caste, color, religion, or sexual
11 | identity and orientation.
12 |
13 | We pledge to act and interact in ways that contribute to an open, welcoming,
14 | diverse, inclusive, and healthy community.
15 |
16 | ## Our Standards
17 |
18 | Examples of behavior that contributes to a positive environment for our
19 | community include:
20 |
21 | * Demonstrating empathy and kindness toward other people
22 | * Being respectful of differing opinions, viewpoints, and experiences
23 | * Giving and gracefully accepting constructive feedback
24 | * Accepting responsibility and apologizing to those affected by our mistakes,
25 | and learning from the experience
26 | * Focusing on what is best not just for us as individuals, but for the overall
27 | community
28 |
29 | Examples of unacceptable behavior include:
30 |
31 | * The use of sexualized language or imagery, and sexual attention or advances of
32 | any kind
33 | * Trolling, insulting or derogatory comments, and personal or political attacks
34 | * Public or private harassment
35 | * Publishing others' private information, such as a physical or email address,
36 | without their explicit permission
37 | * Other conduct which could reasonably be considered inappropriate in a
38 | professional setting
39 |
40 | ## Enforcement Responsibilities
41 |
42 | Community leaders are responsible for clarifying and enforcing our standards of
43 | acceptable behavior and will take appropriate and fair corrective action in
44 | response to any behavior that they deem inappropriate, threatening, offensive,
45 | or harmful.
46 |
47 | Community leaders have the right and responsibility to remove, edit, or reject
48 | comments, commits, code, wiki edits, issues, and other contributions that are
49 | not aligned to this Code of Conduct, and will communicate reasons for moderation
50 | decisions when appropriate.
51 |
52 | ## Scope
53 |
54 | This Code of Conduct applies within all community spaces, and also applies when
55 | an individual is officially representing the community in public spaces.
56 | Examples of representing our community include using an official e-mail address,
57 | posting via an official social media account, or acting as an appointed
58 | representative at an online or offline event.
59 |
60 | ## Enforcement
61 |
62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
63 | reported to the community leaders responsible for enforcement at [coc@buildkite.com](mailto:coc@buildkite.com)
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series of
86 | actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or permanent
93 | ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within the
113 | community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.1, available at
119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120 |
121 | Community Impact Guidelines were inspired by
122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123 |
124 | For answers to common questions about this code of conduct, see the FAQ at
125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126 | [https://www.contributor-covenant.org/translations][translations].
127 |
128 | [homepage]: https://www.contributor-covenant.org
129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130 | [Mozilla CoC]: https://github.com/mozilla/diversity
131 | [FAQ]: https://www.contributor-covenant.org/faq
132 | [translations]: https://www.contributor-covenant.org/translations
133 |
--------------------------------------------------------------------------------
/pkg/trace/trace.go:
--------------------------------------------------------------------------------
1 | package trace
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net/http"
7 |
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/mark3labs/mcp-go/server"
10 | "github.com/rs/zerolog/log"
11 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
12 | "go.opentelemetry.io/otel"
13 | "go.opentelemetry.io/otel/attribute"
14 | "go.opentelemetry.io/otel/codes"
15 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
16 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
17 | "go.opentelemetry.io/otel/propagation"
18 | "go.opentelemetry.io/otel/sdk/resource"
19 | sdktrace "go.opentelemetry.io/otel/sdk/trace"
20 | "go.opentelemetry.io/otel/sdk/trace/tracetest"
21 | semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
22 | "go.opentelemetry.io/otel/trace"
23 | )
24 |
25 | // set a default tracer name
26 | var tracerName = "buildkite-mcp-server"
27 |
28 | func NewProvider(ctx context.Context, exporter, name, version string) (*sdktrace.TracerProvider, error) {
29 | exp, err := newExporter(ctx, exporter)
30 | if err != nil {
31 | return nil, fmt.Errorf("failed to create exporter: %w", err)
32 | }
33 |
34 | res, err := newResource(ctx, name, version)
35 | if err != nil {
36 | return nil, fmt.Errorf("failed to create resource: %w", err)
37 | }
38 |
39 | tp := sdktrace.NewTracerProvider(
40 | sdktrace.WithBatcher(exp),
41 | sdktrace.WithResource(res),
42 | )
43 | otel.SetTracerProvider(tp)
44 |
45 | otel.SetTextMapPropagator(
46 | propagation.NewCompositeTextMapPropagator(
47 | propagation.TraceContext{},
48 | propagation.Baggage{},
49 | ),
50 | )
51 |
52 | tracerName = name
53 |
54 | return tp, nil
55 | }
56 |
57 | func Start(ctx context.Context, name string) (context.Context, trace.Span) {
58 | return otel.GetTracerProvider().Tracer(tracerName).Start(ctx, name)
59 | }
60 |
61 | func NewError(span trace.Span, msg string, args ...any) error {
62 | if span == nil {
63 | return fmt.Errorf("span is nil: %w", fmt.Errorf(msg, args...))
64 | }
65 |
66 | span.RecordError(fmt.Errorf(msg, args...))
67 | span.SetStatus(codes.Error, msg)
68 |
69 | return fmt.Errorf(msg, args...)
70 | }
71 |
72 | func NewHTTPClient() *http.Client {
73 | return &http.Client{
74 | Transport: otelhttp.NewTransport(http.DefaultTransport),
75 | }
76 | }
77 |
78 | // NewHTTPClientWithHeaders returns an http.Client that injects the provided headers into every request.
79 | func NewHTTPClientWithHeaders(headers map[string]string) *http.Client {
80 | return &http.Client{
81 | Transport: &headerInjector{
82 | headers: headers,
83 | wrapped: otelhttp.NewTransport(http.DefaultTransport),
84 | },
85 | }
86 | }
87 |
88 | type headerInjector struct {
89 | headers map[string]string
90 | wrapped http.RoundTripper
91 | }
92 |
93 | func (h *headerInjector) RoundTrip(req *http.Request) (*http.Response, error) {
94 | for k, v := range h.headers {
95 | req.Header.Set(k, v)
96 | }
97 | return h.wrapped.RoundTrip(req)
98 | }
99 |
100 | func newResource(cxt context.Context, name, version string) (*resource.Resource, error) {
101 | options := []resource.Option{
102 | resource.WithSchemaURL(semconv.SchemaURL),
103 | }
104 | options = append(options, resource.WithHost())
105 | options = append(options, resource.WithFromEnv())
106 | options = append(options, resource.WithAttributes(
107 | semconv.TelemetrySDKNameKey.String("otelconfig"),
108 | semconv.TelemetrySDKLanguageGo,
109 | semconv.TelemetrySDKVersionKey.String(version),
110 | ))
111 |
112 | return resource.New(
113 | cxt,
114 | options...,
115 | )
116 | }
117 |
118 | func newExporter(ctx context.Context, exporter string) (sdktrace.SpanExporter, error) {
119 | switch exporter {
120 | case "http/protobuf":
121 | return otlptracehttp.New(ctx)
122 | case "grpc":
123 | return otlptracegrpc.New(ctx)
124 | default:
125 | return tracetest.NewNoopExporter(), nil
126 | }
127 | }
128 |
129 | func NewHooks() *server.Hooks {
130 | hooks := &server.Hooks{}
131 |
132 | hooks.AddOnRegisterSession(func(ctx context.Context, session server.ClientSession) {
133 | span := trace.SpanFromContext(ctx)
134 | if span != nil {
135 | span.SetAttributes(attribute.String("mcp.session.id", session.SessionID()))
136 | }
137 | })
138 |
139 | return hooks
140 | }
141 |
142 | func ToolHandlerFunc(thf server.ToolHandlerFunc) server.ToolHandlerFunc {
143 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
144 | ctx, span := Start(ctx, "mcp.ToolHandler")
145 | defer span.End()
146 |
147 | span.SetAttributes(
148 | attribute.String("mcp.method.name", request.Method),
149 | attribute.String("mcp.tool.name", request.Params.Name),
150 | )
151 |
152 | log.Debug().Str("mcp.tool.name", request.Params.Name).Msg("Handling MCP tool call")
153 |
154 | res, err := thf(ctx, request)
155 | if err != nil {
156 | span.RecordError(err)
157 | span.SetStatus(codes.Error, err.Error())
158 | log.Error().Err(err).Str("mcp.tool.name", request.Params.Name).Msg("Error in MCP tool call")
159 | } else {
160 | span.SetStatus(codes.Ok, "OK")
161 | log.Debug().Str("mcp.tool.name", request.Params.Name).Msg("Completed MCP tool call successfully")
162 | }
163 |
164 | return res, err
165 | }
166 | }
167 |
168 | func WithResourceHandlerFunc(rhf server.ResourceHandlerFunc) server.ResourceHandlerFunc {
169 | return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
170 | ctx, span := Start(ctx, "mcp.ResourceHandler")
171 | defer span.End()
172 |
173 | span.SetAttributes(
174 | attribute.String("mcp.method.name", request.Method),
175 | attribute.String("mcp.resource.uri", request.Params.URI),
176 | )
177 |
178 | log.Debug().Str("mcp.resource.uri", request.Params.URI).Str("mcp.method.name", request.Method).Msg("Handling MCP resource call")
179 |
180 | res, err := rhf(ctx, request)
181 | if err != nil {
182 | span.RecordError(err)
183 | span.SetStatus(codes.Error, err.Error())
184 | log.Error().Err(err).Str("mcp.resource.uri", request.Params.URI).Msg("Error in MCP resource call")
185 | } else {
186 | span.SetStatus(codes.Ok, "OK")
187 | log.Debug().Str("mcp.resource.uri", request.Params.URI).Msg("Completed MCP resource call successfully")
188 | }
189 |
190 | return res, err
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/pkg/buildkite/jobs_test.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "net/http"
7 | "testing"
8 |
9 | "github.com/buildkite/go-buildkite/v4"
10 | "github.com/mark3labs/mcp-go/mcp"
11 | "github.com/stretchr/testify/assert"
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | // MockJobsClient for testing unblock functionality
16 | type MockJobsClient struct {
17 | UnblockJobFunc func(ctx context.Context, org string, pipeline string, buildNumber string, jobID string, opt *buildkite.JobUnblockOptions) (buildkite.Job, *buildkite.Response, error)
18 | }
19 |
20 | func (m *MockJobsClient) UnblockJob(ctx context.Context, org string, pipeline string, buildNumber string, jobID string, opt *buildkite.JobUnblockOptions) (buildkite.Job, *buildkite.Response, error) {
21 | if m.UnblockJobFunc != nil {
22 | return m.UnblockJobFunc(ctx, org, pipeline, buildNumber, jobID, opt)
23 | }
24 | return buildkite.Job{}, &buildkite.Response{}, nil
25 | }
26 |
27 | func TestUnblockJob(t *testing.T) {
28 | ctx := context.Background()
29 |
30 | // Test tool definition
31 | t.Run("ToolDefinition", func(t *testing.T) {
32 | tool, _, _ := UnblockJob(&MockJobsClient{})
33 | assert.Equal(t, "unblock_job", tool.Name)
34 | assert.Contains(t, tool.Description, "Unblock a blocked job")
35 | })
36 |
37 | // Test successful unblock
38 | t.Run("SuccessfulUnblock", func(t *testing.T) {
39 | mockJobs := &MockJobsClient{
40 | UnblockJobFunc: func(ctx context.Context, org string, pipeline string, buildNumber string, jobID string, opt *buildkite.JobUnblockOptions) (buildkite.Job, *buildkite.Response, error) {
41 | assert.Equal(t, "test-org", org)
42 | assert.Equal(t, "test-pipeline", pipeline)
43 | assert.Equal(t, "123", buildNumber)
44 | assert.Equal(t, "job-123", jobID)
45 |
46 | return buildkite.Job{
47 | ID: jobID,
48 | State: "unblocked",
49 | }, &buildkite.Response{
50 | Response: &http.Response{
51 | StatusCode: 200,
52 | },
53 | }, nil
54 | },
55 | }
56 |
57 | _, handler, _ := UnblockJob(mockJobs)
58 |
59 | req := createMCPRequest(t, map[string]any{})
60 | args := UnblockJobArgs{
61 | OrgSlug: "test-org",
62 | PipelineSlug: "test-pipeline",
63 | BuildNumber: "123",
64 | JobID: "job-123",
65 | }
66 |
67 | result, err := handler(ctx, req, args)
68 | require.NoError(t, err)
69 | assert.NotNil(t, result)
70 | assert.Contains(t, result.Content[0].(mcp.TextContent).Text, `"id":"job-123"`)
71 | assert.Contains(t, result.Content[0].(mcp.TextContent).Text, `"state":"unblocked"`)
72 | })
73 |
74 | // Test with fields
75 | t.Run("UnblockWithFields", func(t *testing.T) {
76 | mockJobs := &MockJobsClient{
77 | UnblockJobFunc: func(ctx context.Context, org string, pipeline string, buildNumber string, jobID string, opt *buildkite.JobUnblockOptions) (buildkite.Job, *buildkite.Response, error) {
78 | // Verify fields were passed correctly
79 | require.NotNil(t, opt)
80 | assert.Equal(t, "v1.0.0", opt.Fields["version"])
81 | assert.Equal(t, "prod", opt.Fields["environment"])
82 |
83 | return buildkite.Job{
84 | ID: jobID,
85 | State: "unblocked",
86 | }, &buildkite.Response{
87 | Response: &http.Response{
88 | StatusCode: 200,
89 | },
90 | }, nil
91 | },
92 | }
93 |
94 | _, handler, _ := UnblockJob(mockJobs)
95 |
96 | req := createMCPRequest(t, map[string]any{})
97 | args := UnblockJobArgs{
98 | OrgSlug: "test-org",
99 | PipelineSlug: "test-pipeline",
100 | BuildNumber: "123",
101 | JobID: "job-123",
102 | Fields: map[string]string{"version": "v1.0.0", "environment": "prod"},
103 | }
104 |
105 | result, err := handler(ctx, req, args)
106 | require.NoError(t, err)
107 | assert.NotNil(t, result)
108 | })
109 |
110 | // Test client error
111 | t.Run("ClientError", func(t *testing.T) {
112 | mockJobs := &MockJobsClient{
113 | UnblockJobFunc: func(ctx context.Context, org string, pipeline string, buildNumber string, jobID string, opt *buildkite.JobUnblockOptions) (buildkite.Job, *buildkite.Response, error) {
114 | return buildkite.Job{}, nil, errors.New("API connection failed")
115 | },
116 | }
117 |
118 | _, handler, _ := UnblockJob(mockJobs)
119 |
120 | req := createMCPRequest(t, map[string]any{})
121 | args := UnblockJobArgs{
122 | OrgSlug: "test-org",
123 | PipelineSlug: "test-pipeline",
124 | BuildNumber: "123",
125 | JobID: "job-123",
126 | }
127 |
128 | result, err := handler(ctx, req, args)
129 | require.NoError(t, err)
130 | assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "API connection failed")
131 | })
132 |
133 | // Test missing parameters
134 | t.Run("MissingParameters", func(t *testing.T) {
135 | _, handler, _ := UnblockJob(&MockJobsClient{})
136 |
137 | // Test missing org parameter
138 | req := createMCPRequest(t, map[string]any{})
139 | args := UnblockJobArgs{
140 | PipelineSlug: "test-pipeline",
141 | BuildNumber: "123",
142 | JobID: "job-123",
143 | }
144 | result, err := handler(ctx, req, args)
145 | require.NoError(t, err)
146 | assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "org_slug parameter is required")
147 |
148 | // Test missing pipeline_slug parameter
149 | args = UnblockJobArgs{
150 | OrgSlug: "test-org",
151 | BuildNumber: "123",
152 | JobID: "job-123",
153 | }
154 | result, err = handler(ctx, req, args)
155 | require.NoError(t, err)
156 | assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "pipeline_slug parameter is required")
157 |
158 | // Test missing build_number parameter
159 | args = UnblockJobArgs{
160 | OrgSlug: "test-org",
161 | PipelineSlug: "test-pipeline",
162 | JobID: "job-123",
163 | }
164 | result, err = handler(ctx, req, args)
165 | require.NoError(t, err)
166 | assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "build_number parameter is required")
167 |
168 | // Test missing job_id parameter
169 | args = UnblockJobArgs{
170 | OrgSlug: "test-org",
171 | PipelineSlug: "test-pipeline",
172 | BuildNumber: "123",
173 | }
174 | result, err = handler(ctx, req, args)
175 | require.NoError(t, err)
176 | assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "job_id parameter is required")
177 | })
178 | }
179 |
--------------------------------------------------------------------------------
/pkg/buildkite/buildkite_test.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/buildkite/go-buildkite/v4"
7 | "github.com/mark3labs/mcp-go/mcp"
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | func Test_optionalPaginationParams(t *testing.T) {
12 | tests := []struct {
13 | name string
14 | args map[string]any
15 | expected buildkite.ListOptions
16 | expectErr bool
17 | }{
18 | {
19 | name: "valid pagination parameters",
20 | args: map[string]any{
21 | "page": float64(1),
22 | "perPage": float64(25),
23 | },
24 | expected: buildkite.ListOptions{
25 | Page: 1,
26 | PerPage: 25,
27 | },
28 | expectErr: false,
29 | },
30 | {
31 | name: "missing pagination parameters should use new defaults (100 per page)",
32 | args: map[string]any{
33 | "name": "test-name",
34 | },
35 | expected: buildkite.ListOptions{
36 | Page: 1,
37 | PerPage: 100,
38 | },
39 | expectErr: false,
40 | },
41 | }
42 |
43 | for _, tt := range tests {
44 | t.Run(tt.name, func(t *testing.T) {
45 | assert := require.New(t)
46 | req := createMCPRequest(t, tt.args)
47 |
48 | opts, err := optionalPaginationParams(req)
49 | if tt.expectErr {
50 | assert.Error(err)
51 | } else {
52 | assert.NoError(err)
53 | assert.Equal(tt.expected, opts)
54 | }
55 | })
56 | }
57 | }
58 |
59 | func Test_getClientSidePaginationParams(t *testing.T) {
60 | tests := []struct {
61 | name string
62 | args map[string]any
63 | expectedParams ClientSidePaginationParams
64 | }{
65 | {
66 | name: "valid pagination parameters",
67 | args: map[string]any{
68 | "page": float64(2),
69 | "perPage": float64(10),
70 | },
71 | expectedParams: ClientSidePaginationParams{
72 | Page: 2,
73 | PerPage: 10,
74 | },
75 | },
76 | {
77 | name: "only page parameter",
78 | args: map[string]any{
79 | "page": float64(3),
80 | },
81 | expectedParams: ClientSidePaginationParams{
82 | Page: 3,
83 | PerPage: 25, // default
84 | },
85 | },
86 | {
87 | name: "only perPage parameter",
88 | args: map[string]any{
89 | "perPage": float64(50),
90 | },
91 | expectedParams: ClientSidePaginationParams{
92 | Page: 1, // default
93 | PerPage: 50,
94 | },
95 | },
96 | {
97 | name: "no pagination parameters",
98 | args: map[string]any{
99 | "name": "test-name",
100 | },
101 | expectedParams: ClientSidePaginationParams{
102 | Page: 1, // default
103 | PerPage: 25, // default
104 | },
105 | },
106 | }
107 |
108 | for _, tt := range tests {
109 | t.Run(tt.name, func(t *testing.T) {
110 | assert := require.New(t)
111 | req := createMCPRequest(t, tt.args)
112 |
113 | params := getClientSidePaginationParams(req)
114 | assert.Equal(tt.expectedParams, params)
115 | })
116 | }
117 | }
118 |
119 | func Test_applyClientSidePagination(t *testing.T) {
120 | tests := []struct {
121 | name string
122 | items []string
123 | params ClientSidePaginationParams
124 | expectedResult ClientSidePaginatedResult[string]
125 | }{
126 | {
127 | name: "first page with items",
128 | items: []string{"item1", "item2", "item3", "item4", "item5"},
129 | params: ClientSidePaginationParams{
130 | Page: 1,
131 | PerPage: 2,
132 | },
133 | expectedResult: ClientSidePaginatedResult[string]{
134 | Items: []string{"item1", "item2"},
135 | Page: 1,
136 | PerPage: 2,
137 | Total: 5,
138 | TotalPages: 3,
139 | HasNext: true,
140 | HasPrev: false,
141 | },
142 | },
143 | {
144 | name: "middle page",
145 | items: []string{"item1", "item2", "item3", "item4", "item5"},
146 | params: ClientSidePaginationParams{
147 | Page: 2,
148 | PerPage: 2,
149 | },
150 | expectedResult: ClientSidePaginatedResult[string]{
151 | Items: []string{"item3", "item4"},
152 | Page: 2,
153 | PerPage: 2,
154 | Total: 5,
155 | TotalPages: 3,
156 | HasNext: true,
157 | HasPrev: true,
158 | },
159 | },
160 | {
161 | name: "last page",
162 | items: []string{"item1", "item2", "item3", "item4", "item5"},
163 | params: ClientSidePaginationParams{
164 | Page: 3,
165 | PerPage: 2,
166 | },
167 | expectedResult: ClientSidePaginatedResult[string]{
168 | Items: []string{"item5"},
169 | Page: 3,
170 | PerPage: 2,
171 | Total: 5,
172 | TotalPages: 3,
173 | HasNext: false,
174 | HasPrev: true,
175 | },
176 | },
177 | {
178 | name: "page beyond available data",
179 | items: []string{"item1", "item2"},
180 | params: ClientSidePaginationParams{
181 | Page: 5,
182 | PerPage: 2,
183 | },
184 | expectedResult: ClientSidePaginatedResult[string]{
185 | Items: []string{},
186 | Page: 5,
187 | PerPage: 2,
188 | Total: 2,
189 | TotalPages: 1,
190 | HasNext: false,
191 | HasPrev: true,
192 | },
193 | },
194 | {
195 | name: "empty items",
196 | items: []string{},
197 | params: ClientSidePaginationParams{
198 | Page: 1,
199 | PerPage: 10,
200 | },
201 | expectedResult: ClientSidePaginatedResult[string]{
202 | Items: []string{},
203 | Page: 1,
204 | PerPage: 10,
205 | Total: 0,
206 | TotalPages: 1,
207 | HasNext: false,
208 | HasPrev: false,
209 | },
210 | },
211 | {
212 | name: "page size larger than total items",
213 | items: []string{"item1", "item2"},
214 | params: ClientSidePaginationParams{
215 | Page: 1,
216 | PerPage: 10,
217 | },
218 | expectedResult: ClientSidePaginatedResult[string]{
219 | Items: []string{"item1", "item2"},
220 | Page: 1,
221 | PerPage: 10,
222 | Total: 2,
223 | TotalPages: 1,
224 | HasNext: false,
225 | HasPrev: false,
226 | },
227 | },
228 | }
229 |
230 | for _, tt := range tests {
231 | t.Run(tt.name, func(t *testing.T) {
232 | assert := require.New(t)
233 | result := applyClientSidePagination(tt.items, tt.params)
234 | assert.Equal(tt.expectedResult, result)
235 | })
236 | }
237 | }
238 |
239 | func createMCPRequest(t *testing.T, args map[string]any) mcp.CallToolRequest {
240 | t.Helper()
241 | return mcp.CallToolRequest{
242 | Params: struct {
243 | Name string `json:"name"`
244 | Arguments any `json:"arguments,omitempty"`
245 | Meta *mcp.Meta `json:"_meta,omitempty"`
246 | }{
247 | Arguments: args,
248 | },
249 | }
250 | }
251 |
252 | func getTextResult(t *testing.T, result *mcp.CallToolResult) mcp.TextContent {
253 | t.Helper()
254 | textContent, ok := result.Content[0].(mcp.TextContent)
255 | if !ok {
256 | t.Error("expected text content")
257 | return mcp.TextContent{}
258 | }
259 |
260 | return textContent
261 | }
262 |
--------------------------------------------------------------------------------
/pkg/buildkite/resources/debug-logs-guide.md:
--------------------------------------------------------------------------------
1 | # Debugging Buildkite Build Failures with Logs
2 |
3 | This guide explains how to effectively use the Buildkite MCP server's log tools to debug build failures.
4 |
5 | ## Table of Contents
6 | - [Tools Overview](#tools-overview)
7 | - [Debugging Workflow](#debugging-workflow)
8 | - [Optimizing LLM Usage](#optimizing-llm-usage)
9 | - [Common Error Patterns](#common-error-patterns)
10 | - [Example Investigation](#example-investigation)
11 | - [LLM Prompt Templates](#llm-prompt-templates)
12 |
13 | ## Tools Overview
14 |
15 | The server provides four powerful tools for log analysis:
16 |
17 | ### 1. get_logs_info - Start Here
18 | **Always begin your investigation with this tool** to understand the log file size and scope.
19 |
20 | ```json
21 | {
22 | "org": "",
23 | "pipeline": "",
24 | "build": "",
25 | "job": ""
26 | }
27 | ```
28 |
29 | This helps you plan your debugging approach based on log size.
30 |
31 | ### 2. tail_logs - For Recent Failures
32 | **Best for finding recent errors** - shows the last N log entries where failures typically appear.
33 |
34 | ```json
35 | {
36 | "org": "",
37 | "pipeline": "",
38 | "build": "",
39 | "job": "",
40 | "tail": 50
41 | }
42 | ```
43 |
44 | ### 3. search_logs - For Specific Issues
45 | **Most powerful tool** for finding specific error patterns with context.
46 |
47 | **Key Parameters:**
48 | - `pattern` (required): Regex pattern (POSIX-style, case-insensitive by default)
49 | - `context`: Lines before/after each match (0-20 recommended)
50 | - `before_context`/`after_context`: Asymmetric context
51 | - `case_sensitive`: Enable case-sensitive matching
52 | - `invert_match`: Show non-matching lines
53 | - `reverse`: Search backwards from end
54 | - `seek_start`: Start from specific row (0-based)
55 | - `limit`: Max matches (default: 100)
56 |
57 | ```json
58 | {
59 | "org": "",
60 | "pipeline": "",
61 | "build": "",
62 | "job": "",
63 | "pattern": "error|failed|exception",
64 | "context": 3,
65 | "limit": 20
66 | }
67 | ```
68 |
69 | > ⚠️ **Warning**: Setting `limit` > 200 may exceed LLM context windows.
70 |
71 | ### 4. read_logs - For Sequential Reading
72 | **Use when you need to read specific sections** of logs in order.
73 |
74 | ```json
75 | {
76 | "org": "",
77 | "pipeline": "",
78 | "build": "",
79 | "job": "",
80 | "seek": 1000,
81 | "limit": 100
82 | }
83 | ```
84 |
85 | ## Debugging Workflow
86 |
87 | ### Step 1: Quick Assessment
88 | 1. Start with `get_logs_info` to understand log size
89 | 2. Use `tail_logs` with `tail: 50-100` to see recent entries
90 |
91 | ### Step 2: Error Hunting
92 | 3. Use `search_logs` with common error patterns:
93 | - `error|failed|exception`
94 | - `fatal|panic|abort`
95 | - `timeout|cancelled`
96 | - `permission denied|access denied`
97 |
98 | ### Step 3: Context Investigation
99 | 4. When you find errors, increase `context: 5-10` to see surrounding lines
100 | 5. Use `before_context` and `after_context` for asymmetric context
101 |
102 | ### Step 4: Deep Dive
103 | 6. Use `read_logs` with `seek` to read specific sections around errors
104 | 7. Search for test names, file paths, or specific commands that failed
105 |
106 | ## Sample Response Format
107 |
108 | The `json-terse` format returns entries like:
109 | ```json
110 | {"ts": 1696168225123, "c": "Test failed: assertion error", "rn": 42}
111 | {"ts": 1696168225456, "c": "npm test", "rn": 43}
112 | ```
113 | - `ts`: Timestamp in Unix milliseconds
114 | - `c`: Log content (ANSI codes stripped)
115 | - `rn`: Row number (0-based, use for seeking)
116 |
117 | ## Optimizing LLM Usage
118 |
119 | ### Token Efficiency
120 | - **Always use `format: "json-terse"`** (default) for most efficient token usage
121 | - Provides both log content (`c`) and row numbers (`rn`) for precise pagination
122 | - Automatically strips ANSI escape codes for clean processing
123 | - Most compact representation for AI analysis
124 | - **Always set `limit` parameters** to avoid excessive output
125 | - Use `raw: true` when you only need log content without metadata
126 |
127 | ### Progressive Search Strategy
128 | 1. Start broad with low limits (`limit: 10-20`)
129 | 2. Refine patterns based on findings
130 | 3. Use `invert_match: true` to exclude noise
131 | 4. Use `reverse: true` with `seek_start` to search backwards from known failure points
132 |
133 | ### Context Guidelines
134 | - Use `context: 3-5` for general investigation
135 | - Use `context: 10-20` when you need to understand complex error flows
136 | - Limit context to avoid token waste on unrelated log entries
137 |
138 | ### JSON-Terse Format Benefits
139 | The `json-terse` format is specifically designed for efficient AI processing:
140 | - **Row Numbers**: `rn` field enables precise seeking with `read_logs` for context around found issues
141 | - **Clean Content**: Automatically strips ANSI escape codes that would waste tokens
142 | - **Compact Structure**: Minimal field names (`ts`, `c`, `rn`) reduce overhead
143 | - **Pagination Support**: Use row numbers to fetch precise context around errors
144 |
145 | ## Common Error Patterns
146 |
147 | **Build failures:**
148 | ```
149 | "pattern": "build failed|compilation error|linking error"
150 | ```
151 |
152 | **Test failures:**
153 | ```
154 | "pattern": "test.*failed|assertion.*failed|expected.*but got"
155 | ```
156 |
157 | **Infrastructure issues:**
158 | ```
159 | "pattern": "network.*error|timeout|connection.*refused|dns.*error"
160 | ```
161 |
162 | **Permission/security:**
163 | ```
164 | "pattern": "permission denied|access denied|unauthorized|forbidden"
165 | ```
166 |
167 | ## Example Investigation
168 |
169 | ```json
170 | // 1. Get file overview
171 | {"org": "", "pipeline": "", "build": "", "job": ""}
172 |
173 | // 2. Check recent failures
174 | {"org": "", "pipeline": "", "build": "", "job": "", "tail": 50}
175 |
176 | // 3. Search for errors with context
177 | {"org": "", "pipeline": "", "build": "", "job": "", "pattern": "failed|error", "context": 5, "limit": 15}
178 |
179 | // 4. Deep dive on specific test failures
180 | {"org": "", "pipeline": "", "build": "", "job": "", "pattern": "TestLoginHandler.*failed", "context": 10, "limit": 5}
181 | ```
182 |
183 | ## Cache Management
184 |
185 | - Completed builds are cached permanently
186 | - Running builds use 30s TTL by default
187 | - Use `force_refresh: true` only when you need the absolute latest data
188 | - Set `cache_ttl` appropriately for your investigation needs
189 |
190 | ## LLM Prompt Templates
191 |
192 | For AI assistants debugging build failures:
193 |
194 | ```
195 | Standard debugging workflow:
196 | 1. Call get_logs_info to assess log size
197 | 2. If rows > 1000: use tail_logs with tail=50 first
198 | 3. Search with pattern "(error|failed|timeout)" limit=15 context=3
199 | 4. For each critical error: use read_logs with seek= limit=20
200 | 5. Summarize findings in 3 bullet points max
201 | ```
202 |
203 | **Token estimation guide:**
204 | - `get_logs_info`: ~50 tokens
205 | - `tail_logs` (50 lines): ~800-1200 tokens
206 | - `search_logs` (20 matches): ~1000-2000 tokens
207 | - `read_logs` (100 lines): ~1500-2500 tokens
208 |
209 | > 💡 **Tip**: After collecting log data, summarize key findings to reduce context for follow-up queries.
210 |
211 | This systematic approach will help you quickly identify and understand build failures using the available log tools.
212 |
--------------------------------------------------------------------------------
/pkg/buildkite/joblogs_test.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "testing"
7 | "time"
8 |
9 | "github.com/mark3labs/mcp-go/mcp"
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | // MockBuildkiteLogsClient for testing
14 | type MockBuildkiteLogsClient struct {
15 | DownloadAndCacheFunc func(ctx context.Context, org, pipeline, build, job string, cacheTTL time.Duration, forceRefresh bool) (string, error)
16 | }
17 |
18 | func (m *MockBuildkiteLogsClient) DownloadAndCache(ctx context.Context, org, pipeline, build, job string, cacheTTL time.Duration, forceRefresh bool) (string, error) {
19 | if m.DownloadAndCacheFunc != nil {
20 | return m.DownloadAndCacheFunc(ctx, org, pipeline, build, job, cacheTTL, forceRefresh)
21 | }
22 | return "/tmp/test.parquet", nil
23 | }
24 |
25 | var _ BuildkiteLogsClient = (*MockBuildkiteLogsClient)(nil)
26 |
27 | func TestParseCacheTTL(t *testing.T) {
28 | tests := []struct {
29 | name string
30 | input string
31 | expected time.Duration
32 | }{
33 | {
34 | name: "empty string",
35 | input: "",
36 | expected: 30 * time.Second,
37 | },
38 | {
39 | name: "valid duration",
40 | input: "5m",
41 | expected: 5 * time.Minute,
42 | },
43 | {
44 | name: "invalid duration",
45 | input: "invalid",
46 | expected: 30 * time.Second,
47 | },
48 | {
49 | name: "seconds",
50 | input: "45s",
51 | expected: 45 * time.Second,
52 | },
53 | }
54 |
55 | for _, tt := range tests {
56 | t.Run(tt.name, func(t *testing.T) {
57 | result := parseCacheTTL(tt.input)
58 | require.Equal(t, tt.expected, result)
59 | })
60 | }
61 | }
62 |
63 | func TestValidateSearchPattern(t *testing.T) {
64 | tests := []struct {
65 | name string
66 | pattern string
67 | wantErr bool
68 | }{
69 | {
70 | name: "valid pattern",
71 | pattern: "error",
72 | wantErr: false,
73 | },
74 | {
75 | name: "valid regex",
76 | pattern: "ERROR.*failed",
77 | wantErr: false,
78 | },
79 | {
80 | name: "invalid regex",
81 | pattern: "[",
82 | wantErr: true,
83 | },
84 | {
85 | name: "empty pattern",
86 | pattern: "",
87 | wantErr: false,
88 | },
89 | }
90 |
91 | for _, tt := range tests {
92 | t.Run(tt.name, func(t *testing.T) {
93 | err := validateSearchPattern(tt.pattern)
94 | if tt.wantErr {
95 | require.Error(t, err)
96 | } else {
97 | require.NoError(t, err)
98 | }
99 | })
100 | }
101 | }
102 |
103 | func TestSearchLogsHandler(t *testing.T) {
104 | assert := require.New(t)
105 | ctx := context.Background()
106 |
107 | mockClient := &MockBuildkiteLogsClient{
108 | DownloadAndCacheFunc: func(ctx context.Context, org, pipeline, build, job string, cacheTTL time.Duration, forceRefresh bool) (string, error) {
109 | assert.Equal("test-org", org)
110 | assert.Equal("test-pipeline", pipeline)
111 | assert.Equal("123", build)
112 | assert.Equal("job-456", job)
113 | return "/tmp/test.parquet", nil
114 | },
115 | }
116 |
117 | _, handler, _ := SearchLogs(mockClient)
118 |
119 | t.Run("invalid regex pattern", func(t *testing.T) {
120 | params := SearchLogsParams{
121 | JobLogsBaseParams: JobLogsBaseParams{
122 | OrgSlug: "test-org",
123 | PipelineSlug: "test-pipeline",
124 | BuildNumber: "123",
125 | JobID: "job-456",
126 | },
127 | Pattern: "[", // Invalid regex
128 | }
129 |
130 | result, err := handler(ctx, mcp.CallToolRequest{}, params)
131 | assert.NoError(err)
132 | textContent, ok := result.Content[0].(mcp.TextContent)
133 | assert.True(ok)
134 | assert.Contains(textContent.Text, "invalid regex pattern")
135 | })
136 |
137 | t.Run("client error", func(t *testing.T) {
138 | errorClient := &MockBuildkiteLogsClient{
139 | DownloadAndCacheFunc: func(ctx context.Context, org, pipeline, build, job string, cacheTTL time.Duration, forceRefresh bool) (string, error) {
140 | return "", errors.New("download failed")
141 | },
142 | }
143 |
144 | _, errorHandler, _ := SearchLogs(errorClient)
145 |
146 | params := SearchLogsParams{
147 | JobLogsBaseParams: JobLogsBaseParams{
148 | OrgSlug: "test-org",
149 | PipelineSlug: "test-pipeline",
150 | BuildNumber: "123",
151 | JobID: "job-456",
152 | },
153 | Pattern: "error",
154 | }
155 |
156 | result, err := errorHandler(ctx, mcp.CallToolRequest{}, params)
157 | assert.NoError(err)
158 | textContent, ok := result.Content[0].(mcp.TextContent)
159 | assert.True(ok)
160 | assert.Contains(textContent.Text, "Failed to create log reader")
161 | })
162 | }
163 |
164 | func TestTailLogsHandler(t *testing.T) {
165 | assert := require.New(t)
166 | ctx := context.Background()
167 |
168 | mockClient := &MockBuildkiteLogsClient{
169 | DownloadAndCacheFunc: func(ctx context.Context, org, pipeline, build, job string, cacheTTL time.Duration, forceRefresh bool) (string, error) {
170 | return "/tmp/test.parquet", nil
171 | },
172 | }
173 |
174 | _, handler, _ := TailLogs(mockClient)
175 |
176 | t.Run("default tail value", func(t *testing.T) {
177 | params := TailLogsParams{
178 | JobLogsBaseParams: JobLogsBaseParams{
179 | OrgSlug: "test-org",
180 | PipelineSlug: "test-pipeline",
181 | BuildNumber: "123",
182 | JobID: "job-456",
183 | },
184 | Tail: 0, // Should default to 10
185 | }
186 |
187 | // This will fail due to the parquet file not existing, but we can check the parameters
188 | result, err := handler(ctx, mcp.CallToolRequest{}, params)
189 | assert.NoError(err)
190 | textContent, ok := result.Content[0].(mcp.TextContent)
191 | assert.True(ok)
192 | assert.Contains(textContent.Text, "Failed to get file info")
193 | })
194 | }
195 |
196 | func TestReadLogsHandler(t *testing.T) {
197 | assert := require.New(t)
198 | ctx := context.Background()
199 |
200 | mockClient := &MockBuildkiteLogsClient{
201 | DownloadAndCacheFunc: func(ctx context.Context, org, pipeline, build, job string, cacheTTL time.Duration, forceRefresh bool) (string, error) {
202 | return "/tmp/test.parquet", nil
203 | },
204 | }
205 |
206 | _, handler, _ := ReadLogs(mockClient)
207 |
208 | params := ReadLogsParams{
209 | JobLogsBaseParams: JobLogsBaseParams{
210 | OrgSlug: "test-org",
211 | PipelineSlug: "test-pipeline",
212 | BuildNumber: "123",
213 | JobID: "job-456",
214 | },
215 | Seek: 0,
216 | Limit: 100,
217 | }
218 |
219 | // This will fail due to the parquet file not existing, but we can test the flow
220 | result, err := handler(ctx, mcp.CallToolRequest{}, params)
221 | assert.NoError(err)
222 | textContent, ok := result.Content[0].(mcp.TextContent)
223 | assert.True(ok)
224 | assert.Contains(textContent.Text, "Failed to read entries")
225 | }
226 |
227 | func TestNewParquetReader(t *testing.T) {
228 | assert := require.New(t)
229 | ctx := context.Background()
230 |
231 | t.Run("successful creation", func(t *testing.T) {
232 | mockClient := &MockBuildkiteLogsClient{
233 | DownloadAndCacheFunc: func(ctx context.Context, org, pipeline, build, job string, cacheTTL time.Duration, forceRefresh bool) (string, error) {
234 | assert.Equal("test-org", org)
235 | assert.Equal("test-pipeline", pipeline)
236 | assert.Equal("123", build)
237 | assert.Equal("job-456", job)
238 | assert.Equal(5*time.Minute, cacheTTL)
239 | assert.True(forceRefresh)
240 | return "/tmp/test.parquet", nil
241 | },
242 | }
243 |
244 | params := JobLogsBaseParams{
245 | OrgSlug: "test-org",
246 | PipelineSlug: "test-pipeline",
247 | BuildNumber: "123",
248 | JobID: "job-456",
249 | CacheTTL: "5m",
250 | ForceRefresh: true,
251 | }
252 |
253 | // This will succeed in creating the reader but fail later when trying to read
254 | // the non-existent parquet file, but we can verify the client was called correctly
255 | reader, err := newParquetReader(ctx, mockClient, params)
256 | assert.NoError(err) // Creation succeeds
257 | assert.NotNil(reader) // Reader is created
258 | })
259 |
260 | t.Run("client error", func(t *testing.T) {
261 | mockClient := &MockBuildkiteLogsClient{
262 | DownloadAndCacheFunc: func(ctx context.Context, org, pipeline, build, job string, cacheTTL time.Duration, forceRefresh bool) (string, error) {
263 | return "", errors.New("download failed")
264 | },
265 | }
266 |
267 | params := JobLogsBaseParams{
268 | OrgSlug: "test-org",
269 | PipelineSlug: "test-pipeline",
270 | BuildNumber: "123",
271 | JobID: "job-456",
272 | }
273 |
274 | reader, err := newParquetReader(ctx, mockClient, params)
275 | assert.Error(err)
276 | assert.Nil(reader)
277 | assert.Contains(err.Error(), "failed to download/cache logs")
278 | })
279 | }
280 |
--------------------------------------------------------------------------------
/pkg/buildkite/artifacts.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/base64"
7 | "encoding/json"
8 | "fmt"
9 | "io"
10 | "net/url"
11 | "strings"
12 |
13 | "github.com/buildkite/buildkite-mcp-server/pkg/tokens"
14 | "github.com/buildkite/buildkite-mcp-server/pkg/trace"
15 | "github.com/buildkite/go-buildkite/v4"
16 | "github.com/mark3labs/mcp-go/mcp"
17 | "github.com/mark3labs/mcp-go/server"
18 | "go.opentelemetry.io/otel/attribute"
19 | )
20 |
21 | type ArtifactsClient interface {
22 | ListByBuild(ctx context.Context, org, pipelineSlug, buildNumber string, opts *buildkite.ArtifactListOptions) ([]buildkite.Artifact, *buildkite.Response, error)
23 | ListByJob(ctx context.Context, org, pipelineSlug, buildNumber string, jobID string, opts *buildkite.ArtifactListOptions) ([]buildkite.Artifact, *buildkite.Response, error)
24 | DownloadArtifactByURL(ctx context.Context, url string, writer io.Writer) (*buildkite.Response, error)
25 | }
26 |
27 | // BuildkiteClientAdapter adapts the buildkite.Client to work with our interfaces
28 | type BuildkiteClientAdapter struct {
29 | *buildkite.Client
30 | }
31 |
32 | // ListByBuild implements ArtifactsClient
33 | func (a *BuildkiteClientAdapter) ListByBuild(ctx context.Context, org, pipelineSlug, buildNumber string, opts *buildkite.ArtifactListOptions) ([]buildkite.Artifact, *buildkite.Response, error) {
34 | return a.Artifacts.ListByBuild(ctx, org, pipelineSlug, buildNumber, opts)
35 | }
36 |
37 | // ListByJob implements ArtifactsClient
38 | func (a *BuildkiteClientAdapter) ListByJob(ctx context.Context, org, pipelineSlug, buildNumber string, jobID string, opts *buildkite.ArtifactListOptions) ([]buildkite.Artifact, *buildkite.Response, error) {
39 | return a.Artifacts.ListByJob(ctx, org, pipelineSlug, buildNumber, jobID, opts)
40 | }
41 |
42 | // DownloadArtifactByURL implements ArtifactsClient with URL rewriting support
43 | func (a *BuildkiteClientAdapter) DownloadArtifactByURL(ctx context.Context, url string, writer io.Writer) (*buildkite.Response, error) {
44 | // Rewrite URL if it's using the default Buildkite API URL and we have a custom base URL
45 | rewrittenURL := a.rewriteArtifactURL(url)
46 | return a.Artifacts.DownloadArtifactByURL(ctx, rewrittenURL, writer)
47 | }
48 |
49 | // rewriteArtifactURL rewrites artifact URLs to use the configured base URL
50 | func (a *BuildkiteClientAdapter) rewriteArtifactURL(inputURL string) string {
51 | // Parse the input URL
52 | parsedURL, err := url.Parse(inputURL)
53 | if err != nil {
54 | // If we can't parse the URL, return it as-is
55 | return inputURL
56 | }
57 |
58 | // Get the configured base URL from the client
59 | baseURL := a.BaseURL
60 | if baseURL == nil || baseURL.String() == "" {
61 | return inputURL
62 | }
63 |
64 | // Only rewrite if the base URL is different from the input URL's host and scheme
65 | // and the base URL is non-empty
66 | if baseURL.Host != parsedURL.Host || baseURL.Scheme != parsedURL.Scheme {
67 | // Replace the host and scheme with the configured base URL
68 | parsedURL.Scheme = baseURL.Scheme
69 | parsedURL.Host = baseURL.Host
70 |
71 | // If the base URL has a path prefix, prepend it to the existing path
72 | if baseURL.Path != "" && baseURL.Path != "/" {
73 | // Remove trailing slash from base path if present
74 | basePath := strings.TrimSuffix(baseURL.Path, "/")
75 | parsedURL.Path = basePath + parsedURL.Path
76 | }
77 | }
78 |
79 | return parsedURL.String()
80 | }
81 |
82 | func ListArtifactsForBuild(client ArtifactsClient) (tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) {
83 | return mcp.NewTool("list_artifacts_for_build",
84 | mcp.WithDescription("List all artifacts for a build across all jobs, including file details, paths, sizes, MIME types, and download URLs"),
85 | mcp.WithString("org_slug",
86 | mcp.Required(),
87 | ),
88 | mcp.WithString("pipeline_slug",
89 | mcp.Required(),
90 | ),
91 | mcp.WithString("build_number",
92 | mcp.Required(),
93 | ),
94 | withPagination(),
95 | mcp.WithToolAnnotation(mcp.ToolAnnotation{
96 | Title: "Build Artifact List",
97 | ReadOnlyHint: mcp.ToBoolPtr(true),
98 | }),
99 | ),
100 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
101 | ctx, span := trace.Start(ctx, "buildkite.ListArtifactsForBuild")
102 | defer span.End()
103 |
104 | orgSlug, err := request.RequireString("org_slug")
105 | if err != nil {
106 | return mcp.NewToolResultError(err.Error()), nil
107 | }
108 |
109 | pipelineSlug, err := request.RequireString("pipeline_slug")
110 | if err != nil {
111 | return mcp.NewToolResultError(err.Error()), nil
112 | }
113 |
114 | buildNumber, err := request.RequireString("build_number")
115 | if err != nil {
116 | return mcp.NewToolResultError(err.Error()), nil
117 | }
118 |
119 | paginationParams, err := optionalPaginationParams(request)
120 | if err != nil {
121 | return mcp.NewToolResultError(err.Error()), nil
122 | }
123 |
124 | span.SetAttributes(
125 | attribute.String("org_slug", orgSlug),
126 | attribute.String("pipeline_slug", pipelineSlug),
127 | attribute.String("build_number", buildNumber),
128 | attribute.Int("page", paginationParams.Page),
129 | attribute.Int("per_page", paginationParams.PerPage),
130 | )
131 |
132 | artifacts, resp, err := client.ListByBuild(ctx, orgSlug, pipelineSlug, buildNumber, &buildkite.ArtifactListOptions{
133 | ListOptions: paginationParams,
134 | })
135 | if err != nil {
136 | return mcp.NewToolResultError(err.Error()), nil
137 | }
138 |
139 | result := PaginatedResult[buildkite.Artifact]{
140 | Items: artifacts,
141 | Headers: map[string]string{
142 | "Link": resp.Header.Get("Link"),
143 | },
144 | }
145 |
146 | r, err := json.Marshal(result)
147 | if err != nil {
148 | return nil, fmt.Errorf("failed to marshal artifacts: %w", err)
149 | }
150 |
151 | span.SetAttributes(
152 | attribute.Int("item_count", len(artifacts)),
153 | attribute.Int("estimated_tokens", tokens.EstimateTokens(string(r))),
154 | )
155 |
156 | return mcp.NewToolResultText(string(r)), nil
157 | }, []string{"read_artifacts"}
158 | }
159 |
160 | func ListArtifactsForJob(client ArtifactsClient) (tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) {
161 | return mcp.NewTool("list_artifacts_for_job",
162 | mcp.WithDescription("List all artifacts for an individual job, including file details, paths, sizes, MIME types, and download URLs"),
163 | mcp.WithString("org_slug",
164 | mcp.Required(),
165 | ),
166 | mcp.WithString("pipeline_slug",
167 | mcp.Required(),
168 | ),
169 | mcp.WithString("build_number",
170 | mcp.Required(),
171 | ),
172 | mcp.WithString("job_id",
173 | mcp.Required(),
174 | ),
175 | withPagination(),
176 | mcp.WithToolAnnotation(mcp.ToolAnnotation{
177 | Title: "Job Artifact List",
178 | ReadOnlyHint: mcp.ToBoolPtr(true),
179 | }),
180 | ),
181 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
182 | ctx, span := trace.Start(ctx, "buildkite.ListArtifactsForJob")
183 | defer span.End()
184 |
185 | orgSlug, err := request.RequireString("org_slug")
186 | if err != nil {
187 | return mcp.NewToolResultError(err.Error()), nil
188 | }
189 |
190 | pipelineSlug, err := request.RequireString("pipeline_slug")
191 | if err != nil {
192 | return mcp.NewToolResultError(err.Error()), nil
193 | }
194 |
195 | buildNumber, err := request.RequireString("build_number")
196 | if err != nil {
197 | return mcp.NewToolResultError(err.Error()), nil
198 | }
199 |
200 | jobID, err := request.RequireString("job_id")
201 | if err != nil {
202 | return mcp.NewToolResultError(err.Error()), nil
203 | }
204 |
205 | paginationParams, err := optionalPaginationParams(request)
206 | if err != nil {
207 | return mcp.NewToolResultError(err.Error()), nil
208 | }
209 |
210 | span.SetAttributes(
211 | attribute.String("org_slug", orgSlug),
212 | attribute.String("pipeline_slug", pipelineSlug),
213 | attribute.String("build_number", buildNumber),
214 | attribute.String("job_id", jobID),
215 | attribute.Int("page", paginationParams.Page),
216 | attribute.Int("per_page", paginationParams.PerPage),
217 | )
218 |
219 | artifacts, resp, err := client.ListByJob(ctx, orgSlug, pipelineSlug, buildNumber, jobID, &buildkite.ArtifactListOptions{
220 | ListOptions: paginationParams,
221 | })
222 | if err != nil {
223 | return mcp.NewToolResultError(err.Error()), nil
224 | }
225 |
226 | result := PaginatedResult[buildkite.Artifact]{
227 | Items: artifacts,
228 | Headers: map[string]string{
229 | "Link": resp.Header.Get("Link"),
230 | },
231 | }
232 |
233 | r, err := json.Marshal(result)
234 | if err != nil {
235 | return nil, fmt.Errorf("failed to marshal artifacts: %w", err)
236 | }
237 |
238 | span.SetAttributes(
239 | attribute.Int("item_count", len(artifacts)),
240 | attribute.Int("estimated_tokens", tokens.EstimateTokens(string(r))),
241 | )
242 |
243 | return mcp.NewToolResultText(string(r)), nil
244 | }, []string{"read_artifacts"}
245 | }
246 |
247 | func GetArtifact(client ArtifactsClient) (tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) {
248 | return mcp.NewTool("get_artifact",
249 | mcp.WithDescription("Get detailed information about a specific artifact including its metadata, file size, SHA-1 hash, and download URL"),
250 | mcp.WithString("url",
251 | mcp.Required(),
252 | ),
253 | mcp.WithToolAnnotation(mcp.ToolAnnotation{
254 | Title: "Get Artifact",
255 | ReadOnlyHint: mcp.ToBoolPtr(true),
256 | }),
257 | ),
258 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
259 | ctx, span := trace.Start(ctx, "buildkite.GetArtifact")
260 | defer span.End()
261 |
262 | artifactURL, err := request.RequireString("url")
263 | if err != nil {
264 | return mcp.NewToolResultError(err.Error()), nil
265 | }
266 |
267 | // Validate the URL format
268 | if _, err = url.Parse(artifactURL); err != nil {
269 | return mcp.NewToolResultError(fmt.Sprintf("invalid URL format: %s", err.Error())), nil
270 | }
271 |
272 | span.SetAttributes(attribute.String("url", artifactURL))
273 |
274 | // Use a buffer to capture the artifact data instead of writing directly to stdout
275 | var buffer bytes.Buffer
276 | resp, err := client.DownloadArtifactByURL(ctx, artifactURL, &buffer)
277 | if err != nil {
278 | return mcp.NewToolResultError(fmt.Sprintf("response failed with error %s", err.Error())), nil
279 | }
280 |
281 | // Create a response with the artifact data encoded safely for JSON
282 | result := map[string]any{
283 | "status": resp.Status,
284 | "statusCode": resp.StatusCode,
285 | "data": base64.StdEncoding.EncodeToString(buffer.Bytes()),
286 | "encoding": "base64",
287 | }
288 |
289 | return mcpTextResult(span, &result)
290 | }, []string{"read_artifacts"}
291 | }
292 |
--------------------------------------------------------------------------------
/pkg/buildkite/test_runs_test.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "io"
7 | "net/http"
8 | "strings"
9 | "testing"
10 |
11 | "github.com/buildkite/go-buildkite/v4"
12 | "github.com/mark3labs/mcp-go/mcp"
13 | "github.com/stretchr/testify/require"
14 | )
15 |
16 | type MockTestRunsClient struct {
17 | GetFunc func(ctx context.Context, org, slug, runID string) (buildkite.TestRun, *buildkite.Response, error)
18 | ListFunc func(ctx context.Context, org, slug string, opt *buildkite.TestRunsListOptions) ([]buildkite.TestRun, *buildkite.Response, error)
19 | GetFailedExecutionsFunc func(ctx context.Context, org, slug, runID string, opt *buildkite.FailedExecutionsOptions) ([]buildkite.FailedExecution, *buildkite.Response, error)
20 | }
21 |
22 | func (m *MockTestRunsClient) Get(ctx context.Context, org, slug, runID string) (buildkite.TestRun, *buildkite.Response, error) {
23 | if m.GetFunc != nil {
24 | return m.GetFunc(ctx, org, slug, runID)
25 | }
26 | return buildkite.TestRun{}, nil, nil
27 | }
28 |
29 | func (m *MockTestRunsClient) List(ctx context.Context, org, slug string, opt *buildkite.TestRunsListOptions) ([]buildkite.TestRun, *buildkite.Response, error) {
30 | if m.ListFunc != nil {
31 | return m.ListFunc(ctx, org, slug, opt)
32 | }
33 | return nil, nil, nil
34 | }
35 |
36 | func (m *MockTestRunsClient) GetFailedExecutions(ctx context.Context, org, slug, runID string, opt *buildkite.FailedExecutionsOptions) ([]buildkite.FailedExecution, *buildkite.Response, error) {
37 | if m.GetFailedExecutionsFunc != nil {
38 | return m.GetFailedExecutionsFunc(ctx, org, slug, runID, opt)
39 | }
40 | return nil, nil, nil
41 | }
42 |
43 | var _ TestRunsClient = (*MockTestRunsClient)(nil)
44 |
45 | func TestListTestRuns(t *testing.T) {
46 | assert := require.New(t)
47 |
48 | ctx := context.Background()
49 | testRuns := []buildkite.TestRun{
50 | {
51 | ID: "run1",
52 | URL: "https://api.buildkite.com/v2/analytics/organizations/org/suites/suite1/runs/run1",
53 | WebURL: "https://buildkite.com/org/analytics/suites/suite1/runs/run1",
54 | Branch: "main",
55 | CommitSHA: "abc123",
56 | },
57 | {
58 | ID: "run2",
59 | URL: "https://api.buildkite.com/v2/analytics/organizations/org/suites/suite1/runs/run2",
60 | WebURL: "https://buildkite.com/org/analytics/suites/suite1/runs/run2",
61 | Branch: "feature",
62 | CommitSHA: "def456",
63 | },
64 | }
65 |
66 | mockClient := &MockTestRunsClient{
67 | ListFunc: func(ctx context.Context, org, slug string, opt *buildkite.TestRunsListOptions) ([]buildkite.TestRun, *buildkite.Response, error) {
68 | return testRuns, &buildkite.Response{
69 | Response: &http.Response{
70 | StatusCode: http.StatusOK,
71 | Header: http.Header{"Link": []string{"; rel=\"next\""}},
72 | },
73 | }, nil
74 | },
75 | }
76 |
77 | tool, handler, _ := ListTestRuns(mockClient)
78 |
79 | // Test tool properties
80 | assert.Equal("list_test_runs", tool.Name)
81 | assert.Equal("List all test runs for a test suite in Buildkite Test Engine", tool.Description)
82 | if tool.Annotations.ReadOnlyHint != nil {
83 | assert.True(*tool.Annotations.ReadOnlyHint)
84 | }
85 |
86 | // Test successful request
87 | request := createMCPRequest(t, map[string]any{
88 | "org_slug": "org",
89 | "test_suite_slug": "suite1",
90 | "page": 1,
91 | "perPage": 30,
92 | })
93 |
94 | result, err := handler(ctx, request)
95 | assert.NoError(err)
96 | assert.NotNil(result)
97 |
98 | // Check the result contains paginated data
99 | textContent := result.Content[0].(mcp.TextContent)
100 | assert.Contains(textContent.Text, "run1")
101 | assert.Contains(textContent.Text, "run2")
102 | assert.Contains(textContent.Text, "abc123")
103 | assert.Contains(textContent.Text, "def456")
104 | assert.Contains(textContent.Text, "https://api.buildkite.com/v2/analytics/organizations/org/suites/suite1/runs?page=2")
105 | }
106 |
107 | func TestListTestRunsWithError(t *testing.T) {
108 | assert := require.New(t)
109 |
110 | ctx := context.Background()
111 | mockClient := &MockTestRunsClient{
112 | ListFunc: func(ctx context.Context, org, slug string, opt *buildkite.TestRunsListOptions) ([]buildkite.TestRun, *buildkite.Response, error) {
113 | return []buildkite.TestRun{}, &buildkite.Response{}, fmt.Errorf("API error")
114 | },
115 | }
116 |
117 | _, handler, _ := ListTestRuns(mockClient)
118 |
119 | request := createMCPRequest(t, map[string]any{
120 | "org_slug": "org",
121 | "test_suite_slug": "suite1",
122 | })
123 |
124 | result, err := handler(ctx, request)
125 | assert.NoError(err)
126 | assert.True(result.IsError)
127 | assert.Contains(result.Content[0].(mcp.TextContent).Text, "API error")
128 | }
129 |
130 | func TestListTestRunsMissingOrg(t *testing.T) {
131 | assert := require.New(t)
132 |
133 | ctx := context.Background()
134 | mockClient := &MockTestRunsClient{}
135 |
136 | _, handler, _ := ListTestRuns(mockClient)
137 |
138 | request := createMCPRequest(t, map[string]any{
139 | "test_suite_slug": "suite1",
140 | })
141 |
142 | result, err := handler(ctx, request)
143 | assert.NoError(err)
144 | assert.True(result.IsError)
145 | assert.Contains(result.Content[0].(mcp.TextContent).Text, "org")
146 | }
147 |
148 | func TestListTestRunsMissingTestSuiteSlug(t *testing.T) {
149 | assert := require.New(t)
150 |
151 | ctx := context.Background()
152 | mockClient := &MockTestRunsClient{}
153 |
154 | _, handler, _ := ListTestRuns(mockClient)
155 |
156 | request := createMCPRequest(t, map[string]any{
157 | "org_slug": "org",
158 | })
159 |
160 | result, err := handler(ctx, request)
161 | assert.NoError(err)
162 | assert.True(result.IsError)
163 | assert.Contains(result.Content[0].(mcp.TextContent).Text, "test_suite_slug")
164 | }
165 |
166 | func TestGetTestRun(t *testing.T) {
167 | assert := require.New(t)
168 |
169 | ctx := context.Background()
170 | testRun := buildkite.TestRun{
171 | ID: "run1",
172 | URL: "https://api.buildkite.com/v2/analytics/organizations/org/suites/suite1/runs/run1",
173 | WebURL: "https://buildkite.com/org/analytics/suites/suite1/runs/run1",
174 | Branch: "main",
175 | CommitSHA: "abc123",
176 | }
177 |
178 | mockClient := &MockTestRunsClient{
179 | GetFunc: func(ctx context.Context, org, slug, runID string) (buildkite.TestRun, *buildkite.Response, error) {
180 | return testRun, &buildkite.Response{
181 | Response: &http.Response{
182 | StatusCode: http.StatusOK,
183 | },
184 | }, nil
185 | },
186 | }
187 |
188 | tool, handler, _ := GetTestRun(mockClient)
189 |
190 | // Test tool properties
191 | assert.Equal("get_test_run", tool.Name)
192 | assert.Equal("Get a specific test run in Buildkite Test Engine", tool.Description)
193 | if tool.Annotations.ReadOnlyHint != nil {
194 | assert.True(*tool.Annotations.ReadOnlyHint)
195 | }
196 |
197 | // Test successful request
198 | request := createMCPRequest(t, map[string]any{
199 | "org_slug": "org",
200 | "test_suite_slug": "suite1",
201 | "run_id": "run1",
202 | })
203 |
204 | result, err := handler(ctx, request)
205 | assert.NoError(err)
206 | assert.NotNil(result)
207 |
208 | // Check the result contains test run data
209 | textContent := result.Content[0].(mcp.TextContent)
210 | assert.Contains(textContent.Text, "run1")
211 | assert.Contains(textContent.Text, "abc123")
212 | assert.Contains(textContent.Text, "main")
213 | }
214 |
215 | func TestGetTestRunWithError(t *testing.T) {
216 | assert := require.New(t)
217 |
218 | ctx := context.Background()
219 | mockClient := &MockTestRunsClient{
220 | GetFunc: func(ctx context.Context, org, slug, runID string) (buildkite.TestRun, *buildkite.Response, error) {
221 | return buildkite.TestRun{}, &buildkite.Response{}, fmt.Errorf("API error")
222 | },
223 | }
224 |
225 | _, handler, _ := GetTestRun(mockClient)
226 |
227 | request := createMCPRequest(t, map[string]any{
228 | "org_slug": "org",
229 | "test_suite_slug": "suite1",
230 | "run_id": "run1",
231 | })
232 |
233 | result, err := handler(ctx, request)
234 | assert.NoError(err)
235 | assert.True(result.IsError)
236 | assert.Contains(result.Content[0].(mcp.TextContent).Text, "API error")
237 | }
238 |
239 | func TestGetTestRunMissingOrg(t *testing.T) {
240 | assert := require.New(t)
241 |
242 | ctx := context.Background()
243 | mockClient := &MockTestRunsClient{}
244 |
245 | _, handler, _ := GetTestRun(mockClient)
246 |
247 | request := createMCPRequest(t, map[string]any{
248 | "test_suite_slug": "suite1",
249 | "run_id": "run1",
250 | })
251 |
252 | result, err := handler(ctx, request)
253 | assert.NoError(err)
254 | assert.True(result.IsError)
255 | assert.Contains(result.Content[0].(mcp.TextContent).Text, "org")
256 | }
257 |
258 | func TestGetTestRunMissingTestSuiteSlug(t *testing.T) {
259 | assert := require.New(t)
260 |
261 | ctx := context.Background()
262 | mockClient := &MockTestRunsClient{}
263 |
264 | _, handler, _ := GetTestRun(mockClient)
265 |
266 | request := createMCPRequest(t, map[string]any{
267 | "org_slug": "org",
268 | "run_id": "run1",
269 | })
270 |
271 | result, err := handler(ctx, request)
272 | assert.NoError(err)
273 | assert.True(result.IsError)
274 | assert.Contains(result.Content[0].(mcp.TextContent).Text, "test_suite_slug")
275 | }
276 |
277 | func TestGetTestRunMissingRunID(t *testing.T) {
278 | assert := require.New(t)
279 |
280 | ctx := context.Background()
281 | mockClient := &MockTestRunsClient{}
282 |
283 | _, handler, _ := GetTestRun(mockClient)
284 |
285 | request := createMCPRequest(t, map[string]any{
286 | "org_slug": "org",
287 | "test_suite_slug": "suite1",
288 | })
289 |
290 | result, err := handler(ctx, request)
291 | assert.NoError(err)
292 | assert.True(result.IsError)
293 | assert.Contains(result.Content[0].(mcp.TextContent).Text, "run_id")
294 | }
295 |
296 | func TestGetTestRunReturnsBuildID(t *testing.T) {
297 | // This test documents the expected behavior that build_id should be returned
298 | // from the GetTestRun endpoint, as per the Buildkite API documentation:
299 | // https://buildkite.com/docs/apis/rest-api/test-engine/runs
300 | assert := require.New(t)
301 |
302 | ctx := context.Background()
303 | testRun := buildkite.TestRun{
304 | ID: "run1",
305 | URL: "https://api.buildkite.com/v2/analytics/organizations/org/suites/suite1/runs/run1",
306 | WebURL: "https://buildkite.com/org/analytics/suites/suite1/runs/run1",
307 | Branch: "main",
308 | CommitSHA: "abc123",
309 | BuildID: "89c02425-7712-4ee5-a694-c94b56b4d54c",
310 | }
311 |
312 | mockClient := &MockTestRunsClient{
313 | GetFunc: func(ctx context.Context, org, slug, runID string) (buildkite.TestRun, *buildkite.Response, error) {
314 | return testRun, &buildkite.Response{
315 | Response: &http.Response{
316 | StatusCode: http.StatusOK,
317 | },
318 | }, nil
319 | },
320 | }
321 |
322 | _, handler, _ := GetTestRun(mockClient)
323 |
324 | request := createMCPRequest(t, map[string]any{
325 | "org_slug": "org",
326 | "test_suite_slug": "suite1",
327 | "run_id": "run1",
328 | })
329 |
330 | result, err := handler(ctx, request)
331 | assert.NoError(err)
332 | assert.NotNil(result)
333 |
334 | textContent := result.Content[0].(mcp.TextContent)
335 |
336 | assert.Contains(textContent.Text, "build_id", "TestRun response should contain build_id field per Buildkite API spec")
337 | }
338 |
339 | func TestGetTestRunHTTPError(t *testing.T) {
340 | assert := require.New(t)
341 |
342 | ctx := context.Background()
343 | mockClient := &MockTestRunsClient{
344 | GetFunc: func(ctx context.Context, org, slug, runID string) (buildkite.TestRun, *buildkite.Response, error) {
345 | return buildkite.TestRun{}, &buildkite.Response{
346 | Response: &http.Response{
347 | StatusCode: http.StatusNotFound,
348 | Body: io.NopCloser(strings.NewReader("Test run not found")),
349 | },
350 | }, nil
351 | },
352 | }
353 |
354 | _, handler, _ := GetTestRun(mockClient)
355 |
356 | request := createMCPRequest(t, map[string]any{
357 | "org_slug": "org",
358 | "test_suite_slug": "suite1",
359 | "run_id": "run1",
360 | })
361 |
362 | result, err := handler(ctx, request)
363 | assert.NoError(err)
364 | assert.True(result.IsError)
365 | assert.Contains(result.Content[0].(mcp.TextContent).Text, "Test run not found")
366 | }
367 |
368 | func TestListTestRunsHTTPError(t *testing.T) {
369 | assert := require.New(t)
370 |
371 | ctx := context.Background()
372 | mockClient := &MockTestRunsClient{
373 | ListFunc: func(ctx context.Context, org, slug string, opt *buildkite.TestRunsListOptions) ([]buildkite.TestRun, *buildkite.Response, error) {
374 | resp := &http.Response{
375 | Request: &http.Request{Method: "GET"},
376 | StatusCode: http.StatusForbidden,
377 | Body: io.NopCloser(strings.NewReader("Access denied")),
378 | }
379 | return []buildkite.TestRun{}, &buildkite.Response{
380 | Response: resp,
381 | }, &buildkite.ErrorResponse{Response: resp, Message: "Access denied"}
382 | },
383 | }
384 |
385 | _, handler, _ := ListTestRuns(mockClient)
386 |
387 | request := createMCPRequest(t, map[string]any{
388 | "org_slug": "org",
389 | "test_suite_slug": "suite1",
390 | })
391 |
392 | result, err := handler(ctx, request)
393 | assert.NoError(err)
394 | assert.True(result.IsError)
395 | assert.Contains(result.Content[0].(mcp.TextContent).Text, "Access denied")
396 | }
397 |
--------------------------------------------------------------------------------
/pkg/buildkite/pipelines_test.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "net/http"
7 | "testing"
8 |
9 | "github.com/buildkite/go-buildkite/v4"
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | type MockPipelinesClient struct {
14 | GetFunc func(ctx context.Context, org string, pipeline string) (buildkite.Pipeline, *buildkite.Response, error)
15 | ListFunc func(ctx context.Context, org string, opt *buildkite.PipelineListOptions) ([]buildkite.Pipeline, *buildkite.Response, error)
16 | CreateFunc func(ctx context.Context, org string, p buildkite.CreatePipeline) (buildkite.Pipeline, *buildkite.Response, error)
17 | UpdateFunc func(ctx context.Context, org string, pipeline string, p buildkite.UpdatePipeline) (buildkite.Pipeline, *buildkite.Response, error)
18 | AddWebhookFunc func(ctx context.Context, org string, slug string) (*buildkite.Response, error)
19 | }
20 |
21 | func (m *MockPipelinesClient) Get(ctx context.Context, org string, pipeline string) (buildkite.Pipeline, *buildkite.Response, error) {
22 | if m.GetFunc != nil {
23 | return m.GetFunc(ctx, org, pipeline)
24 | }
25 | return buildkite.Pipeline{}, nil, nil
26 | }
27 |
28 | func (m *MockPipelinesClient) List(ctx context.Context, org string, opt *buildkite.PipelineListOptions) ([]buildkite.Pipeline, *buildkite.Response, error) {
29 | if m.ListFunc != nil {
30 | return m.ListFunc(ctx, org, opt)
31 | }
32 | return nil, nil, nil
33 | }
34 |
35 | func (m *MockPipelinesClient) Create(ctx context.Context, org string, p buildkite.CreatePipeline) (buildkite.Pipeline, *buildkite.Response, error) {
36 | if m.CreateFunc != nil {
37 | return m.CreateFunc(ctx, org, p)
38 | }
39 | return buildkite.Pipeline{}, nil, nil
40 | }
41 |
42 | func (m *MockPipelinesClient) Update(ctx context.Context, org string, pipeline string, p buildkite.UpdatePipeline) (buildkite.Pipeline, *buildkite.Response, error) {
43 | if m.UpdateFunc != nil {
44 | return m.UpdateFunc(ctx, org, pipeline, p)
45 | }
46 | return buildkite.Pipeline{}, nil, nil
47 | }
48 |
49 | func (m *MockPipelinesClient) AddWebhook(ctx context.Context, org string, slug string) (*buildkite.Response, error) {
50 | if m.AddWebhookFunc != nil {
51 | return m.AddWebhookFunc(ctx, org, slug)
52 | }
53 | return &buildkite.Response{Response: &http.Response{StatusCode: 201}}, nil
54 | }
55 |
56 | var _ PipelinesClient = (*MockPipelinesClient)(nil)
57 |
58 | func TestListPipelines(t *testing.T) {
59 | assert := require.New(t)
60 |
61 | ctx := context.Background()
62 | client := &MockPipelinesClient{
63 | ListFunc: func(ctx context.Context, org string, opt *buildkite.PipelineListOptions) ([]buildkite.Pipeline, *buildkite.Response, error) {
64 | return []buildkite.Pipeline{
65 | {
66 | ID: "123",
67 | Slug: "test-pipeline",
68 | Name: "Test Pipeline",
69 | CreatedAt: &buildkite.Timestamp{},
70 | },
71 | }, &buildkite.Response{
72 | Response: &http.Response{
73 | StatusCode: 200,
74 | },
75 | }, nil
76 | },
77 | }
78 |
79 | tool, handler, _ := ListPipelines(client)
80 | assert.NotNil(tool)
81 | assert.NotNil(handler)
82 |
83 | request := createMCPRequest(t, map[string]any{})
84 |
85 | args := ListPipelinesArgs{
86 | OrgSlug: "org",
87 | }
88 |
89 | result, err := handler(ctx, request, args)
90 | assert.NoError(err)
91 |
92 | textContent := getTextResult(t, result)
93 |
94 | assert.JSONEq(`{"headers":{"Link":""},"items":[{"id":"123","name":"Test Pipeline","slug":"test-pipeline","repository":"","default_branch":"","web_url":"","visibility":"","created_at":"0001-01-01T00:00:00Z"}]}`, textContent.Text)
95 | }
96 |
97 | func TestGetPipeline(t *testing.T) {
98 | assert := require.New(t)
99 |
100 | ctx := context.Background()
101 | client := &MockPipelinesClient{
102 | GetFunc: func(ctx context.Context, org string, pipeline string) (buildkite.Pipeline, *buildkite.Response, error) {
103 | return buildkite.Pipeline{
104 | ID: "123",
105 | Slug: "test-pipeline",
106 | Name: "Test Pipeline",
107 | CreatedAt: &buildkite.Timestamp{},
108 | }, &buildkite.Response{
109 | Response: &http.Response{
110 | StatusCode: 200,
111 | },
112 | }, nil
113 | },
114 | }
115 |
116 | tool, handler, _ := GetPipeline(client)
117 | assert.NotNil(tool)
118 | assert.NotNil(handler)
119 |
120 | request := createMCPRequest(t, map[string]any{})
121 |
122 | args := GetPipelineArgs{
123 | OrgSlug: "org",
124 | PipelineSlug: "pipeline",
125 | }
126 |
127 | result, err := handler(ctx, request, args)
128 | assert.NoError(err)
129 |
130 | textContent := getTextResult(t, result)
131 |
132 | assert.JSONEq(`{"id":"123","name":"Test Pipeline","slug":"test-pipeline","created_at":"0001-01-01T00:00:00Z","skip_queued_branch_builds":false,"cancel_running_branch_builds":false,"provider":{"id":"","webhook_url":"","settings":null}}`, textContent.Text)
133 | }
134 |
135 | func TestCreatePipeline(t *testing.T) {
136 | assert := require.New(t)
137 |
138 | testPipelineDefinition := `
139 | agents:
140 | queue: "something"
141 | env:
142 | TEST_ENV_VAR: "value"
143 | steps:
144 | - command: "echo Hello World"
145 | key: "hello_step"
146 | label: "Hello Step"
147 | `
148 |
149 | ctx := context.Background()
150 | webhookCalled := false
151 | client := &MockPipelinesClient{
152 | CreateFunc: func(ctx context.Context, org string, p buildkite.CreatePipeline) (buildkite.Pipeline, *buildkite.Response, error) {
153 | // validate required fields
154 | assert.Equal("org", org)
155 | assert.Equal("cluster-123", p.ClusterID)
156 | assert.Equal("Test Pipeline", p.Name)
157 | assert.Equal("https://example.com/repo.git", p.Repository)
158 | assert.Equal(testPipelineDefinition, p.Configuration)
159 |
160 | return buildkite.Pipeline{
161 | ID: "123",
162 | Slug: "test-pipeline",
163 | Name: "Test Pipeline",
164 | ClusterID: "cluster-123",
165 | CreatedAt: &buildkite.Timestamp{},
166 | Tags: []string{"tag1", "tag2"},
167 | }, &buildkite.Response{
168 | Response: &http.Response{
169 | StatusCode: 200,
170 | },
171 | }, nil
172 | },
173 | AddWebhookFunc: func(ctx context.Context, org string, slug string) (*buildkite.Response, error) {
174 | assert.Equal("org", org)
175 | assert.Equal("test-pipeline", slug)
176 | webhookCalled = true
177 | return &buildkite.Response{
178 | Response: &http.Response{
179 | StatusCode: 201,
180 | },
181 | }, nil
182 | },
183 | }
184 |
185 | tool, handler, _ := CreatePipeline(client)
186 | assert.NotNil(tool)
187 | assert.NotNil(handler)
188 |
189 | request := createMCPRequest(t, map[string]any{})
190 |
191 | args := CreatePipelineArgs{
192 | OrgSlug: "org",
193 | Name: "Test Pipeline",
194 | ClusterID: "cluster-123",
195 | RepositoryURL: "https://example.com/repo.git",
196 | Description: "A test pipeline",
197 | Configuration: testPipelineDefinition,
198 | Tags: []string{"tag1", "tag2"},
199 | CreateWebhook: true, // should create webhook by default
200 | }
201 |
202 | result, err := handler(ctx, request, args)
203 | assert.NoError(err)
204 | assert.True(webhookCalled, "AddWebhook should have been called when CreateWebhook is true")
205 |
206 | textContent := getTextResult(t, result)
207 | assert.Contains(textContent.Text, `"webhook":{"created":true,"note":"Pipeline and webhook created successfully."}`)
208 | assert.Contains(textContent.Text, `"pipeline":{"id":"123","name":"Test Pipeline","slug":"test-pipeline"`)
209 | }
210 |
211 | func TestCreatePipelineWithWebhook(t *testing.T) {
212 | assert := require.New(t)
213 |
214 | testPipelineDefinition := `
215 | agents:
216 | queue: "something"
217 | env:
218 | TEST_ENV_VAR: "value"
219 | steps:
220 | - command: "echo Hello World"
221 | key: "hello_step"
222 | label: "Hello Step"
223 | `
224 |
225 | ctx := context.Background()
226 | webhookCalled := false
227 | client := &MockPipelinesClient{
228 | CreateFunc: func(ctx context.Context, org string, p buildkite.CreatePipeline) (buildkite.Pipeline, *buildkite.Response, error) {
229 | // validate required fields
230 | assert.Equal("org", org)
231 | assert.Equal("Test Pipeline", p.Name)
232 | assert.Equal("https://github.com/example/repo.git", p.Repository)
233 | assert.Equal("cluster-123", p.ClusterID)
234 | assert.Equal(testPipelineDefinition, p.Configuration)
235 |
236 | return buildkite.Pipeline{
237 | ID: "123",
238 | Slug: "test-pipeline",
239 | Name: "Test Pipeline",
240 | ClusterID: "cluster-123",
241 | CreatedAt: &buildkite.Timestamp{},
242 | Tags: []string{"tag1", "tag2"},
243 | }, &buildkite.Response{
244 | Response: &http.Response{
245 | StatusCode: 201,
246 | },
247 | }, nil
248 | },
249 | AddWebhookFunc: func(ctx context.Context, org string, slug string) (*buildkite.Response, error) {
250 | // validate required fields
251 | assert.Equal("org", org)
252 | assert.Equal("test-pipeline", slug)
253 |
254 | webhookCalled = true
255 | return &buildkite.Response{
256 | Response: &http.Response{
257 | StatusCode: 201,
258 | },
259 | }, nil
260 | },
261 | }
262 |
263 | tool, handler, _ := CreatePipeline(client)
264 | assert.NotNil(tool)
265 | assert.NotNil(handler)
266 |
267 | request := createMCPRequest(t, map[string]any{})
268 |
269 | args := CreatePipelineArgs{
270 | OrgSlug: "org",
271 | Name: "Test Pipeline",
272 | ClusterID: "cluster-123",
273 | RepositoryURL: "https://github.com/example/repo.git",
274 | Description: "A test pipeline",
275 | Configuration: testPipelineDefinition,
276 | Tags: []string{"tag1", "tag2"},
277 | CreateWebhook: true,
278 | }
279 |
280 | result, err := handler(ctx, request, args)
281 | assert.NoError(err)
282 | assert.True(webhookCalled, "AddWebhook should have been called")
283 |
284 | textContent := getTextResult(t, result)
285 | assert.Contains(textContent.Text, `"webhook":{"created":true,"note":"Pipeline and webhook created successfully."}`)
286 | assert.Contains(textContent.Text, `"pipeline":{"id":"123","name":"Test Pipeline","slug":"test-pipeline"`)
287 | }
288 |
289 | func TestCreatePipelineWithWebhookError(t *testing.T) {
290 | assert := require.New(t)
291 |
292 | testPipelineDefinition := `
293 | agents:
294 | queue: "something"
295 | env:
296 | TEST_ENV_VAR: "value"
297 | steps:
298 | - command: "echo Hello World"
299 | key: "hello_step"
300 | label: "Hello Step"
301 | `
302 |
303 | ctx := context.Background()
304 | webhookCalled := false
305 | client := &MockPipelinesClient{
306 | CreateFunc: func(ctx context.Context, org string, p buildkite.CreatePipeline) (buildkite.Pipeline, *buildkite.Response, error) {
307 | // validate required fields
308 | assert.Equal("org", org)
309 | assert.Equal("Test Pipeline", p.Name)
310 | assert.Equal("https://github.com/example/repo.git", p.Repository)
311 | assert.Equal("cluster-123", p.ClusterID)
312 | assert.Equal(testPipelineDefinition, p.Configuration)
313 |
314 | return buildkite.Pipeline{
315 | ID: "123",
316 | Slug: "test-pipeline",
317 | Name: "Test Pipeline",
318 | ClusterID: "cluster-123",
319 | CreatedAt: &buildkite.Timestamp{},
320 | Tags: []string{"tag1", "tag2"},
321 | }, &buildkite.Response{
322 | Response: &http.Response{
323 | StatusCode: 201,
324 | },
325 | }, nil
326 | },
327 | AddWebhookFunc: func(ctx context.Context, org string, slug string) (*buildkite.Response, error) {
328 | webhookCalled = true
329 | return nil, errors.New("Auto-creating webhooks is not supported for your repository.")
330 | },
331 | }
332 |
333 | tool, handler, _ := CreatePipeline(client)
334 | assert.NotNil(tool)
335 | assert.NotNil(handler)
336 |
337 | request := createMCPRequest(t, map[string]any{})
338 |
339 | args := CreatePipelineArgs{
340 | OrgSlug: "org",
341 | Name: "Test Pipeline",
342 | ClusterID: "cluster-123",
343 | RepositoryURL: "https://github.com/example/repo.git",
344 | Description: "A test pipeline",
345 | Configuration: testPipelineDefinition,
346 | Tags: []string{"tag1", "tag2"},
347 | CreateWebhook: true,
348 | }
349 |
350 | result, err := handler(ctx, request, args)
351 | assert.NoError(err)
352 | assert.True(webhookCalled, "AddWebhook should have been called")
353 |
354 | textContent := getTextResult(t, result)
355 | assert.Contains(textContent.Text, `"webhook":{"created":false,`)
356 | assert.Contains(textContent.Text, `"error":"Auto-creating webhooks is not supported for your repository."`)
357 | assert.Contains(textContent.Text, `"note":"Pipeline created successfully, but webhook creation failed.`)
358 | }
359 |
360 | func TestUpdatePipeline(t *testing.T) {
361 | assert := require.New(t)
362 |
363 | testPipelineDefinition := `agents:
364 | queue: "something"
365 | env:
366 | TEST_ENV_VAR: "value"
367 | steps:
368 | - command: "echo Hello World"
369 | key: "hello_step"
370 | label: "Hello Step"
371 | `
372 | ctx := context.Background()
373 | client := &MockPipelinesClient{
374 | UpdateFunc: func(ctx context.Context, org string, pipeline string, p buildkite.UpdatePipeline) (buildkite.Pipeline, *buildkite.Response, error) {
375 | // validate required fields
376 | assert.Equal("org", org)
377 | assert.Equal("test-pipeline", pipeline)
378 |
379 | assert.Equal(testPipelineDefinition, p.Configuration)
380 |
381 | return buildkite.Pipeline{
382 | ID: "123",
383 | Slug: "test-pipeline",
384 | Name: "Test Pipeline",
385 | ClusterID: "abc-123",
386 | CreatedAt: &buildkite.Timestamp{},
387 | Tags: []string{"tag1", "tag2"},
388 | }, &buildkite.Response{
389 | Response: &http.Response{
390 | StatusCode: 200,
391 | },
392 | }, nil
393 | },
394 | }
395 |
396 | tool, handler, _ := UpdatePipeline(client)
397 | assert.NotNil(tool)
398 | assert.NotNil(handler)
399 |
400 | request := createMCPRequest(t, map[string]any{})
401 |
402 | args := UpdatePipelineArgs{
403 | OrgSlug: "org",
404 | PipelineSlug: "test-pipeline",
405 | Name: "Test Pipeline",
406 | ClusterID: "abc-123",
407 | Description: "A test pipeline",
408 | Configuration: testPipelineDefinition,
409 | RepositoryURL: "https://example.com/repo.git",
410 | Tags: []string{"tag1", "tag2"},
411 | }
412 | result, err := handler(ctx, request, args)
413 | assert.NoError(err)
414 | textContent := getTextResult(t, result)
415 | assert.JSONEq(`{"id":"123","name":"Test Pipeline","slug":"test-pipeline","created_at":"0001-01-01T00:00:00Z","skip_queued_branch_builds":false,"cancel_running_branch_builds":false,"cluster_id":"abc-123","tags":["tag1","tag2"],"provider":{"id":"","webhook_url":"","settings":null}}`, textContent.Text)
416 | }
417 |
--------------------------------------------------------------------------------
/pkg/buildkite/test_executions_test.go:
--------------------------------------------------------------------------------
1 | package buildkite
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "io"
7 | "net/http"
8 | "strings"
9 | "testing"
10 |
11 | "github.com/buildkite/go-buildkite/v4"
12 | "github.com/mark3labs/mcp-go/mcp"
13 | "github.com/stretchr/testify/require"
14 | )
15 |
16 | type MockTestExecutionsClient struct {
17 | GetFailedExecutionsFunc func(ctx context.Context, org, slug, runID string, opt *buildkite.FailedExecutionsOptions) ([]buildkite.FailedExecution, *buildkite.Response, error)
18 | }
19 |
20 | func (m *MockTestExecutionsClient) GetFailedExecutions(ctx context.Context, org, slug, runID string, opt *buildkite.FailedExecutionsOptions) ([]buildkite.FailedExecution, *buildkite.Response, error) {
21 | if m.GetFailedExecutionsFunc != nil {
22 | return m.GetFailedExecutionsFunc(ctx, org, slug, runID, opt)
23 | }
24 | return nil, nil, nil
25 | }
26 |
27 | var _ TestExecutionsClient = (*MockTestExecutionsClient)(nil)
28 |
29 | func TestGetFailedExecutions(t *testing.T) {
30 | assert := require.New(t)
31 |
32 | ctx := context.Background()
33 | failedExecutions := []buildkite.FailedExecution{
34 | {
35 | ExecutionID: "exec-1",
36 | RunID: "run-123",
37 | TestID: "test-456",
38 | TestName: "Test Case 1",
39 | FailureReason: "Assertion failed",
40 | Duration: 1.5,
41 | },
42 | {
43 | ExecutionID: "exec-2",
44 | RunID: "run-123",
45 | TestID: "test-789",
46 | TestName: "Test Case 2",
47 | FailureReason: "Timeout",
48 | Duration: 30.0,
49 | },
50 | }
51 |
52 | mockClient := &MockTestExecutionsClient{
53 | GetFailedExecutionsFunc: func(ctx context.Context, org, slug, runID string, opt *buildkite.FailedExecutionsOptions) ([]buildkite.FailedExecution, *buildkite.Response, error) {
54 | return failedExecutions, &buildkite.Response{
55 | Response: &http.Response{
56 | StatusCode: http.StatusOK,
57 | },
58 | }, nil
59 | },
60 | }
61 |
62 | tool, handler, _ := GetFailedTestExecutions(mockClient)
63 |
64 | // Test tool properties
65 | assert.Equal("get_failed_executions", tool.Name)
66 | assert.Equal("Get failed test executions for a specific test run in Buildkite Test Engine. Optionally get the expanded failure details such as full error messages and stack traces.", tool.Description)
67 | if tool.Annotations.ReadOnlyHint != nil {
68 | assert.True(*tool.Annotations.ReadOnlyHint)
69 | }
70 |
71 | // Test successful request
72 | request := createMCPRequest(t, map[string]any{
73 | "org_slug": "org",
74 | "test_suite_slug": "suite1",
75 | "run_id": "run1",
76 | "include_failure_expanded": true,
77 | })
78 |
79 | result, err := handler(ctx, request)
80 | assert.NoError(err)
81 | assert.NotNil(result)
82 |
83 | // Check the result contains failed execution data
84 | textContent := result.Content[0].(mcp.TextContent)
85 | assert.Contains(textContent.Text, "exec-1")
86 | assert.Contains(textContent.Text, "exec-2")
87 | assert.Contains(textContent.Text, "Test Case 1")
88 | assert.Contains(textContent.Text, "Assertion failed")
89 | assert.Contains(textContent.Text, "Timeout")
90 | // Should always have pagination metadata (defaults: page=1, per_page=25)
91 | assert.Contains(textContent.Text, `"page":1`)
92 | assert.Contains(textContent.Text, `"per_page":25`)
93 | assert.Contains(textContent.Text, `"total":2`)
94 | }
95 |
96 | func TestGetFailedExecutionsMissingOrg(t *testing.T) {
97 | assert := require.New(t)
98 |
99 | ctx := context.Background()
100 | mockClient := &MockTestExecutionsClient{}
101 |
102 | _, handler, _ := GetFailedTestExecutions(mockClient)
103 |
104 | request := createMCPRequest(t, map[string]any{
105 | "test_suite_slug": "suite1",
106 | "run_id": "run1",
107 | })
108 |
109 | result, err := handler(ctx, request)
110 | assert.NoError(err)
111 | assert.True(result.IsError)
112 | assert.Contains(result.Content[0].(mcp.TextContent).Text, "org")
113 | }
114 |
115 | func TestGetFailedExecutionsMissingTestSuiteSlug(t *testing.T) {
116 | assert := require.New(t)
117 |
118 | ctx := context.Background()
119 | mockClient := &MockTestExecutionsClient{}
120 |
121 | _, handler, _ := GetFailedTestExecutions(mockClient)
122 |
123 | request := createMCPRequest(t, map[string]any{
124 | "org_slug": "org",
125 | "run_id": "run1",
126 | })
127 |
128 | result, err := handler(ctx, request)
129 | assert.NoError(err)
130 | assert.True(result.IsError)
131 | assert.Contains(result.Content[0].(mcp.TextContent).Text, "test_suite_slug")
132 | }
133 |
134 | func TestGetFailedExecutionsMissingRunID(t *testing.T) {
135 | assert := require.New(t)
136 |
137 | ctx := context.Background()
138 | mockClient := &MockTestExecutionsClient{}
139 |
140 | _, handler, _ := GetFailedTestExecutions(mockClient)
141 |
142 | request := createMCPRequest(t, map[string]any{
143 | "org_slug": "org",
144 | "test_suite_slug": "suite1",
145 | })
146 |
147 | result, err := handler(ctx, request)
148 | assert.NoError(err)
149 | assert.True(result.IsError)
150 | assert.Contains(result.Content[0].(mcp.TextContent).Text, "run_id")
151 | }
152 |
153 | func TestGetFailedExecutionsWithError(t *testing.T) {
154 | assert := require.New(t)
155 |
156 | ctx := context.Background()
157 | mockClient := &MockTestExecutionsClient{
158 | GetFailedExecutionsFunc: func(ctx context.Context, org, slug, runID string, opt *buildkite.FailedExecutionsOptions) ([]buildkite.FailedExecution, *buildkite.Response, error) {
159 | return []buildkite.FailedExecution{}, &buildkite.Response{}, fmt.Errorf("API error")
160 | },
161 | }
162 |
163 | _, handler, _ := GetFailedTestExecutions(mockClient)
164 |
165 | request := createMCPRequest(t, map[string]any{
166 | "org_slug": "org",
167 | "test_suite_slug": "suite1",
168 | "run_id": "run1",
169 | })
170 |
171 | result, err := handler(ctx, request)
172 | assert.NoError(err)
173 | assert.True(result.IsError)
174 | assert.Contains(result.Content[0].(mcp.TextContent).Text, "API error")
175 | }
176 |
177 | func TestGetFailedExecutionsHTTPError(t *testing.T) {
178 | assert := require.New(t)
179 |
180 | ctx := context.Background()
181 | mockClient := &MockTestExecutionsClient{
182 | GetFailedExecutionsFunc: func(ctx context.Context, org, slug, runID string, opt *buildkite.FailedExecutionsOptions) ([]buildkite.FailedExecution, *buildkite.Response, error) {
183 | resp := &http.Response{
184 | Request: &http.Request{
185 | Method: "GET",
186 | URL: nil,
187 | },
188 | StatusCode: http.StatusNotFound,
189 | Body: io.NopCloser(strings.NewReader("Failed executions not found")),
190 | }
191 |
192 | return []buildkite.FailedExecution{}, &buildkite.Response{
193 | Response: resp,
194 | }, &buildkite.ErrorResponse{Response: resp, Message: "Failed executions not found"}
195 | },
196 | }
197 |
198 | _, handler, _ := GetFailedTestExecutions(mockClient)
199 |
200 | request := createMCPRequest(t, map[string]any{
201 | "org_slug": "org",
202 | "test_suite_slug": "suite1",
203 | "run_id": "run1",
204 | })
205 |
206 | result, err := handler(ctx, request)
207 | assert.NoError(err)
208 | assert.True(result.IsError)
209 | assert.Contains(result.Content[0].(mcp.TextContent).Text, "Failed executions not found")
210 | }
211 |
212 | func TestGetFailedExecutionsPagination(t *testing.T) {
213 | assert := require.New(t)
214 |
215 | ctx := context.Background()
216 | // Create 6 failed executions to test pagination
217 | failedExecutions := []buildkite.FailedExecution{
218 | {
219 | ExecutionID: "exec-1",
220 | RunID: "run-123",
221 | TestID: "test-456",
222 | TestName: "Test Case 1",
223 | FailureReason: "Assertion failed",
224 | Duration: 1.5,
225 | },
226 | {
227 | ExecutionID: "exec-2",
228 | RunID: "run-123",
229 | TestID: "test-789",
230 | TestName: "Test Case 2",
231 | FailureReason: "Timeout",
232 | Duration: 30.0,
233 | },
234 | {
235 | ExecutionID: "exec-3",
236 | RunID: "run-123",
237 | TestID: "test-101",
238 | TestName: "Test Case 3",
239 | FailureReason: "Network error",
240 | Duration: 5.0,
241 | },
242 | {
243 | ExecutionID: "exec-4",
244 | RunID: "run-123",
245 | TestID: "test-102",
246 | TestName: "Test Case 4",
247 | FailureReason: "Database error",
248 | Duration: 2.5,
249 | },
250 | {
251 | ExecutionID: "exec-5",
252 | RunID: "run-123",
253 | TestID: "test-103",
254 | TestName: "Test Case 5",
255 | FailureReason: "Memory leak",
256 | Duration: 10.0,
257 | },
258 | {
259 | ExecutionID: "exec-6",
260 | RunID: "run-123",
261 | TestID: "test-104",
262 | TestName: "Test Case 6",
263 | FailureReason: "Segmentation fault",
264 | Duration: 0.1,
265 | },
266 | }
267 |
268 | mockClient := &MockTestExecutionsClient{
269 | GetFailedExecutionsFunc: func(ctx context.Context, org, slug, runID string, opt *buildkite.FailedExecutionsOptions) ([]buildkite.FailedExecution, *buildkite.Response, error) {
270 | return failedExecutions, &buildkite.Response{
271 | Response: &http.Response{
272 | StatusCode: http.StatusOK,
273 | },
274 | }, nil
275 | },
276 | }
277 |
278 | tool, handler, _ := GetFailedTestExecutions(mockClient)
279 | assert.NotNil(tool)
280 | assert.NotNil(handler)
281 |
282 | // Test first page with page size of 2
283 | requestFirstPage := createMCPRequest(t, map[string]any{
284 | "org_slug": "org",
285 | "test_suite_slug": "suite1",
286 | "run_id": "run1",
287 | "page": float64(1),
288 | "perPage": float64(2),
289 | })
290 | resultFirstPage, err := handler(ctx, requestFirstPage)
291 | assert.NoError(err)
292 |
293 | textContentFirstPage := resultFirstPage.Content[0].(mcp.TextContent)
294 | // Should contain first 2 executions
295 | assert.Contains(textContentFirstPage.Text, "exec-1")
296 | assert.Contains(textContentFirstPage.Text, "exec-2")
297 | assert.NotContains(textContentFirstPage.Text, "exec-3")
298 | assert.NotContains(textContentFirstPage.Text, "exec-4")
299 | // Should have pagination metadata
300 | assert.Contains(textContentFirstPage.Text, `"page":1`)
301 | assert.Contains(textContentFirstPage.Text, `"per_page":2`)
302 | assert.Contains(textContentFirstPage.Text, `"total":6`)
303 | assert.Contains(textContentFirstPage.Text, `"has_next":true`)
304 | assert.Contains(textContentFirstPage.Text, `"has_prev":false`)
305 |
306 | // Test second page with page size of 2
307 | requestSecondPage := createMCPRequest(t, map[string]any{
308 | "org_slug": "org",
309 | "test_suite_slug": "suite1",
310 | "run_id": "run1",
311 | "page": float64(2),
312 | "perPage": float64(2),
313 | })
314 | resultSecondPage, err := handler(ctx, requestSecondPage)
315 | assert.NoError(err)
316 |
317 | textContentSecondPage := resultSecondPage.Content[0].(mcp.TextContent)
318 | // Should contain next 2 executions
319 | assert.NotContains(textContentSecondPage.Text, "exec-1")
320 | assert.NotContains(textContentSecondPage.Text, "exec-2")
321 | assert.Contains(textContentSecondPage.Text, "exec-3")
322 | assert.Contains(textContentSecondPage.Text, "exec-4")
323 | // Should have pagination metadata
324 | assert.Contains(textContentSecondPage.Text, `"page":2`)
325 | assert.Contains(textContentSecondPage.Text, `"per_page":2`)
326 | assert.Contains(textContentSecondPage.Text, `"total":6`)
327 | assert.Contains(textContentSecondPage.Text, `"has_next":true`)
328 | assert.Contains(textContentSecondPage.Text, `"has_prev":true`)
329 |
330 | // Test last page
331 | requestLastPage := createMCPRequest(t, map[string]any{
332 | "org_slug": "org",
333 | "test_suite_slug": "suite1",
334 | "run_id": "run1",
335 | "page": float64(3),
336 | "perPage": float64(2),
337 | })
338 | resultLastPage, err := handler(ctx, requestLastPage)
339 | assert.NoError(err)
340 |
341 | textContentLastPage := resultLastPage.Content[0].(mcp.TextContent)
342 | // Should contain last 2 executions
343 | assert.Contains(textContentLastPage.Text, "exec-5")
344 | assert.Contains(textContentLastPage.Text, "exec-6")
345 | // Should have pagination metadata
346 | assert.Contains(textContentLastPage.Text, `"page":3`)
347 | assert.Contains(textContentLastPage.Text, `"per_page":2`)
348 | assert.Contains(textContentLastPage.Text, `"total":6`)
349 | assert.Contains(textContentLastPage.Text, `"has_next":false`)
350 | assert.Contains(textContentLastPage.Text, `"has_prev":true`)
351 |
352 | // Test page beyond available data
353 | requestBeyond := createMCPRequest(t, map[string]any{
354 | "org_slug": "org",
355 | "test_suite_slug": "suite1",
356 | "run_id": "run1",
357 | "page": float64(5),
358 | "perPage": float64(2),
359 | })
360 | resultBeyond, err := handler(ctx, requestBeyond)
361 | assert.NoError(err)
362 |
363 | textContentBeyond := resultBeyond.Content[0].(mcp.TextContent)
364 | // Should contain empty items array
365 | assert.Contains(textContentBeyond.Text, `"items":[]`)
366 | }
367 |
368 | func TestGetFailedExecutionsLargePage(t *testing.T) {
369 | assert := require.New(t)
370 |
371 | ctx := context.Background()
372 | failedExecutions := []buildkite.FailedExecution{
373 | {
374 | ExecutionID: "exec-1",
375 | RunID: "run-123",
376 | TestID: "test-456",
377 | TestName: "Test Case 1",
378 | FailureReason: "Assertion failed",
379 | Duration: 1.5,
380 | },
381 | {
382 | ExecutionID: "exec-2",
383 | RunID: "run-123",
384 | TestID: "test-789",
385 | TestName: "Test Case 2",
386 | FailureReason: "Timeout",
387 | Duration: 30.0,
388 | },
389 | }
390 |
391 | mockClient := &MockTestExecutionsClient{
392 | GetFailedExecutionsFunc: func(ctx context.Context, org, slug, runID string, opt *buildkite.FailedExecutionsOptions) ([]buildkite.FailedExecution, *buildkite.Response, error) {
393 | return failedExecutions, &buildkite.Response{
394 | Response: &http.Response{
395 | StatusCode: http.StatusOK,
396 | },
397 | }, nil
398 | },
399 | }
400 |
401 | tool, handler, _ := GetFailedTestExecutions(mockClient)
402 | assert.NotNil(tool)
403 | assert.NotNil(handler)
404 |
405 | // Test with page size larger than available data
406 | requestLargePage := createMCPRequest(t, map[string]any{
407 | "org_slug": "org",
408 | "test_suite_slug": "suite1",
409 | "run_id": "run1",
410 | "page": float64(1),
411 | "perPage": float64(10),
412 | })
413 | resultLargePage, err := handler(ctx, requestLargePage)
414 | assert.NoError(err)
415 |
416 | textContentLargePage := resultLargePage.Content[0].(mcp.TextContent)
417 | // Should contain all executions
418 | assert.Contains(textContentLargePage.Text, "exec-1")
419 | assert.Contains(textContentLargePage.Text, "exec-2")
420 | // Should have pagination metadata
421 | assert.Contains(textContentLargePage.Text, `"page":1`)
422 | assert.Contains(textContentLargePage.Text, `"per_page":10`)
423 | assert.Contains(textContentLargePage.Text, `"total":2`)
424 | assert.Contains(textContentLargePage.Text, `"has_next":false`)
425 | assert.Contains(textContentLargePage.Text, `"has_prev":false`)
426 | }
427 |
--------------------------------------------------------------------------------
/pkg/toolsets/toolsets.go:
--------------------------------------------------------------------------------
1 | package toolsets
2 |
3 | import (
4 | "fmt"
5 | "slices"
6 |
7 | buildkitelogs "github.com/buildkite/buildkite-logs"
8 | "github.com/buildkite/buildkite-mcp-server/pkg/buildkite"
9 | gobuildkite "github.com/buildkite/go-buildkite/v4"
10 | "github.com/mark3labs/mcp-go/mcp"
11 | "github.com/mark3labs/mcp-go/server"
12 | )
13 |
14 | // ToolDefinition wraps an MCP tool with additional metadata
15 | type ToolDefinition struct {
16 | Tool mcp.Tool
17 | Handler server.ToolHandlerFunc
18 | RequiredScopes []string // Buildkite API token scopes required for this tool
19 | }
20 |
21 | // IsReadOnly returns true if the tool is read-only
22 | func (td ToolDefinition) IsReadOnly() bool {
23 | if td.Tool.Annotations.ReadOnlyHint == nil {
24 | return false
25 | }
26 | return *td.Tool.Annotations.ReadOnlyHint
27 | }
28 |
29 | // Toolset represents a logical grouping of related tools
30 | type Toolset struct {
31 | Name string
32 | Description string
33 | Tools []ToolDefinition
34 | }
35 |
36 | // GetReadOnlyTools returns only the read-only tools from this toolset
37 | func (ts Toolset) GetReadOnlyTools() []ToolDefinition {
38 | var readOnlyTools []ToolDefinition
39 | for _, tool := range ts.Tools {
40 | if tool.IsReadOnly() {
41 | readOnlyTools = append(readOnlyTools, tool)
42 | }
43 | }
44 | return readOnlyTools
45 | }
46 |
47 | // GetAllTools returns all tools from this toolset
48 | func (ts Toolset) GetAllTools() []ToolDefinition {
49 | return ts.Tools
50 | }
51 |
52 | // GetRequiredScopes returns all unique scopes required by tools in this toolset
53 | func (ts Toolset) GetRequiredScopes() []string {
54 | scopeMap := make(map[string]bool)
55 | for _, tool := range ts.Tools {
56 | for _, scope := range tool.RequiredScopes {
57 | scopeMap[scope] = true
58 | }
59 | }
60 |
61 | scopes := make([]string, 0, len(scopeMap))
62 | for scope := range scopeMap {
63 | scopes = append(scopes, scope)
64 | }
65 | slices.Sort(scopes)
66 | return scopes
67 | }
68 |
69 | // ToolsetRegistry manages the registration and discovery of toolsets
70 | type ToolsetRegistry struct {
71 | toolsets map[string]Toolset
72 | }
73 |
74 | // NewToolsetRegistry creates a new toolset registry
75 | func NewToolsetRegistry() *ToolsetRegistry {
76 | return &ToolsetRegistry{
77 | toolsets: make(map[string]Toolset),
78 | }
79 | }
80 |
81 | // Register adds a toolset to the registry
82 | func (tr *ToolsetRegistry) Register(name string, toolset Toolset) {
83 | tr.toolsets[name] = toolset
84 | }
85 |
86 | func (tr *ToolsetRegistry) RegisterToolsets(toolsets map[string]Toolset) {
87 | for name, toolset := range toolsets {
88 | tr.Register(name, toolset)
89 | }
90 | }
91 |
92 | // Get retrieves a toolset by name
93 | func (tr *ToolsetRegistry) Get(name string) (Toolset, bool) {
94 | toolset, exists := tr.toolsets[name]
95 | return toolset, exists
96 | }
97 |
98 | // GetToolsForToolsets returns tools from specified toolset names, optionally filtering for read-only
99 | func (tr *ToolsetRegistry) GetToolsForToolsets(toolsetNames []string, readOnlyMode bool) []ToolDefinition {
100 | var tools []ToolDefinition
101 |
102 | for _, name := range toolsetNames {
103 | if toolset, exists := tr.toolsets[name]; exists {
104 | if readOnlyMode {
105 | tools = append(tools, toolset.GetReadOnlyTools()...)
106 | } else {
107 | tools = append(tools, toolset.GetAllTools()...)
108 | }
109 | }
110 | }
111 |
112 | return tools
113 | }
114 |
115 | // List returns all registered toolset names
116 | func (tr *ToolsetRegistry) List() []string {
117 | names := make([]string, 0, len(tr.toolsets))
118 | for name := range tr.toolsets {
119 | names = append(names, name)
120 | }
121 | slices.Sort(names)
122 | return names
123 | }
124 |
125 | // GetEnabledTools returns tools from enabled toolsets, optionally filtering for read-only
126 | func (tr *ToolsetRegistry) GetEnabledTools(enabledToolsets []string, readOnlyMode bool) []ToolDefinition {
127 | var tools []ToolDefinition
128 |
129 | // If "all" is specified, enable all toolsets
130 | if slices.Contains(enabledToolsets, "all") {
131 | enabledToolsets = tr.List()
132 | }
133 |
134 | for _, toolsetName := range enabledToolsets {
135 | if toolset, exists := tr.toolsets[toolsetName]; exists {
136 | if readOnlyMode {
137 | tools = append(tools, toolset.GetReadOnlyTools()...)
138 | } else {
139 | tools = append(tools, toolset.GetAllTools()...)
140 | }
141 | }
142 | }
143 |
144 | return tools
145 | }
146 |
147 | // ToolsetMetadata provides information about a toolset for introspection
148 | type ToolsetMetadata struct {
149 | Name string `json:"name"`
150 | Description string `json:"description"`
151 | ToolCount int `json:"tool_count"`
152 | ReadOnlyCount int `json:"read_only_count"`
153 | }
154 |
155 | // GetMetadata returns metadata for all registered toolsets
156 | func (tr *ToolsetRegistry) GetMetadata() []ToolsetMetadata {
157 | metadata := make([]ToolsetMetadata, 0, len(tr.toolsets))
158 |
159 | for name, toolset := range tr.toolsets {
160 | readOnlyCount := len(toolset.GetReadOnlyTools())
161 | metadata = append(metadata, ToolsetMetadata{
162 | Name: name,
163 | Description: toolset.Description,
164 | ToolCount: len(toolset.Tools),
165 | ReadOnlyCount: readOnlyCount,
166 | })
167 | }
168 |
169 | // Sort by name for consistency
170 | slices.SortFunc(metadata, func(a, b ToolsetMetadata) int {
171 | if a.Name < b.Name {
172 | return -1
173 | } else if a.Name > b.Name {
174 | return 1
175 | }
176 | return 0
177 | })
178 |
179 | return metadata
180 | }
181 |
182 | // GetRequiredScopes returns all unique scopes required by enabled toolsets
183 | func (tr *ToolsetRegistry) GetRequiredScopes(enabledToolsets []string, readOnlyMode bool) []string {
184 | scopeMap := make(map[string]bool)
185 |
186 | // If "all" is specified, enable all toolsets
187 | if slices.Contains(enabledToolsets, "all") {
188 | enabledToolsets = tr.List()
189 | }
190 |
191 | for _, toolsetName := range enabledToolsets {
192 | if toolset, exists := tr.toolsets[toolsetName]; exists {
193 | var tools []ToolDefinition
194 | if readOnlyMode {
195 | tools = toolset.GetReadOnlyTools()
196 | } else {
197 | tools = toolset.GetAllTools()
198 | }
199 |
200 | for _, tool := range tools {
201 | for _, scope := range tool.RequiredScopes {
202 | scopeMap[scope] = true
203 | }
204 | }
205 | }
206 | }
207 |
208 | scopes := make([]string, 0, len(scopeMap))
209 | for scope := range scopeMap {
210 | scopes = append(scopes, scope)
211 | }
212 | slices.Sort(scopes)
213 | return scopes
214 | }
215 |
216 | // NewTool creates a new tool definition with annotations based on access level
217 | func NewTool(tool mcp.Tool, handler server.ToolHandlerFunc, scopes []string) ToolDefinition {
218 | return ToolDefinition{
219 | Tool: tool,
220 | Handler: handler,
221 | RequiredScopes: scopes,
222 | }
223 | }
224 |
225 | const (
226 | ToolsetAll = "all" // Special name to enable all toolsets
227 | ToolsetClusters = "clusters"
228 | ToolsetPipelines = "pipelines"
229 | ToolsetBuilds = "builds"
230 | ToolsetArtifacts = "artifacts"
231 | ToolsetLogs = "logs"
232 | ToolsetTests = "tests"
233 | ToolsetAnnotations = "annotations"
234 | ToolsetUser = "user"
235 | )
236 |
237 | var ValidToolsets = []string{
238 | ToolsetAll,
239 | ToolsetClusters,
240 | ToolsetPipelines,
241 | ToolsetBuilds,
242 | ToolsetArtifacts,
243 | ToolsetLogs,
244 | ToolsetTests,
245 | ToolsetAnnotations,
246 | ToolsetUser,
247 | }
248 |
249 | // IsValidToolset checks if a toolset name is valid
250 | func IsValidToolset(name string) bool {
251 | return slices.Contains(ValidToolsets, name)
252 | }
253 |
254 | // ValidateToolsets checks if all toolset names are valid
255 | func ValidateToolsets(names []string) error {
256 | invalidToolsets := []string{}
257 |
258 | for _, name := range names {
259 | if !IsValidToolset(name) {
260 | invalidToolsets = append(invalidToolsets, name)
261 | }
262 | }
263 | if len(invalidToolsets) > 0 {
264 | return fmt.Errorf("invalid toolset names: %v", invalidToolsets)
265 | }
266 | return nil
267 | }
268 |
269 | // CreateBuiltinToolsets creates the default toolsets with all available tools
270 | func CreateBuiltinToolsets(client *gobuildkite.Client, buildkiteLogsClient *buildkitelogs.Client) map[string]Toolset {
271 | // Create a client adapter for artifact tools
272 | clientAdapter := &buildkite.BuildkiteClientAdapter{Client: client}
273 |
274 | return map[string]Toolset{
275 | ToolsetClusters: {
276 | Name: "Cluster Management",
277 | Description: "Tools for managing Buildkite clusters and cluster queues",
278 | Tools: []ToolDefinition{
279 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) { return buildkite.GetCluster(client.Clusters) }),
280 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) { return buildkite.ListClusters(client.Clusters) }),
281 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
282 | return buildkite.GetClusterQueue(client.ClusterQueues)
283 | }),
284 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
285 | return buildkite.ListClusterQueues(client.ClusterQueues)
286 | }),
287 | },
288 | },
289 | ToolsetPipelines: {
290 | Name: "Pipeline Management",
291 | Description: "Tools for managing Buildkite pipelines",
292 | Tools: []ToolDefinition{
293 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
294 | tool, handler, scopes := buildkite.GetPipeline(client.Pipelines)
295 | return tool, mcp.NewTypedToolHandler(handler), scopes
296 | }),
297 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
298 | tool, handler, scopes := buildkite.ListPipelines(client.Pipelines)
299 | return tool, mcp.NewTypedToolHandler(handler), scopes
300 | }),
301 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
302 | tool, handler, scopes := buildkite.CreatePipeline(client.Pipelines)
303 | return tool, mcp.NewTypedToolHandler(handler), scopes
304 | }),
305 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
306 | tool, handler, scopes := buildkite.UpdatePipeline(client.Pipelines)
307 | return tool, mcp.NewTypedToolHandler(handler), scopes
308 | }),
309 | },
310 | },
311 | ToolsetBuilds: {
312 | Name: "Build Operations",
313 | Description: "Tools for managing builds and jobs",
314 | Tools: []ToolDefinition{
315 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
316 | tool, handler, scopes := buildkite.ListBuilds(client.Builds)
317 | return tool, mcp.NewTypedToolHandler(handler), scopes
318 | }),
319 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
320 | tool, handler, scopes := buildkite.GetBuild(client.Builds)
321 | return tool, mcp.NewTypedToolHandler(handler), scopes
322 | }),
323 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
324 | tool, handler, scopes := buildkite.GetBuildTestEngineRuns(client.Builds)
325 | return tool, mcp.NewTypedToolHandler(handler), scopes
326 | }),
327 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
328 | tool, handler, scopes := buildkite.CreateBuild(client.Builds)
329 | return tool, mcp.NewTypedToolHandler(handler), scopes
330 | }),
331 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
332 | tool, handler, scopes := buildkite.WaitForBuild(client.Builds)
333 | return tool, mcp.NewTypedToolHandler(handler), scopes
334 | }),
335 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
336 | tool, handler, scopes := buildkite.UnblockJob(client.Jobs)
337 | return tool, mcp.NewTypedToolHandler(handler), scopes
338 | }),
339 | },
340 | },
341 | ToolsetArtifacts: {
342 | Name: "Artifact Management",
343 | Description: "Tools for managing build artifacts",
344 | Tools: []ToolDefinition{
345 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
346 | return buildkite.ListArtifactsForBuild(clientAdapter)
347 | }),
348 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
349 | return buildkite.ListArtifactsForJob(clientAdapter)
350 | }),
351 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) { return buildkite.GetArtifact(clientAdapter) }),
352 | },
353 | },
354 | ToolsetTests: {
355 | Name: "Test Engine",
356 | Description: "Tools for managing test runs and test results",
357 | Tools: []ToolDefinition{
358 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) { return buildkite.ListTestRuns(client.TestRuns) }),
359 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) { return buildkite.GetTestRun(client.TestRuns) }),
360 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
361 | return buildkite.GetFailedTestExecutions(client.TestRuns)
362 | }),
363 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) { return buildkite.GetTest(client.Tests) }),
364 | },
365 | },
366 | ToolsetLogs: {
367 | Name: "Log Management",
368 | Description: "Tools for searching, reading, and analyzing job logs",
369 | Tools: []ToolDefinition{
370 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
371 | tool, handler, scopes := buildkite.SearchLogs(buildkiteLogsClient)
372 | return tool, mcp.NewTypedToolHandler(handler), scopes
373 | }),
374 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
375 | tool, handler, scopes := buildkite.TailLogs(buildkiteLogsClient)
376 | return tool, mcp.NewTypedToolHandler(handler), scopes
377 | }),
378 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
379 | tool, handler, scopes := buildkite.ReadLogs(buildkiteLogsClient)
380 | return tool, mcp.NewTypedToolHandler(handler), scopes
381 | }),
382 | },
383 | },
384 | ToolsetAnnotations: {
385 | Name: "Annotation Management",
386 | Description: "Tools for managing build annotations",
387 | Tools: []ToolDefinition{
388 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
389 | return buildkite.ListAnnotations(client.Annotations)
390 | }),
391 | },
392 | },
393 | ToolsetUser: {
394 | Name: "User & Organization",
395 | Description: "Tools for user and organization information",
396 | Tools: []ToolDefinition{
397 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) { return buildkite.CurrentUser(client.User) }),
398 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) {
399 | return buildkite.UserTokenOrganization(client.Organizations)
400 | }),
401 | newToolFromFunc(func() (mcp.Tool, server.ToolHandlerFunc, []string) { return buildkite.AccessToken(client.AccessTokens) }),
402 | },
403 | },
404 | }
405 | }
406 |
407 | // newToolFromFunc creates a new ToolDefinition from a function that returns (tool, handler, scopes)
408 | func newToolFromFunc(toolFunc func() (mcp.Tool, server.ToolHandlerFunc, []string)) ToolDefinition {
409 | tool, handler, scopes := toolFunc()
410 | return NewTool(tool, handler, scopes)
411 | }
412 |
--------------------------------------------------------------------------------