├── 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 | [![Build status](https://badge.buildkite.com/79fefd75bc7f1898fb35249f7ebd8541a99beef6776e7da1b4.svg?branch=main)](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 mapTest 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 | --------------------------------------------------------------------------------