Skip to content

Commit 84d9b86

Browse files
authored
Merge pull request #46 from RemyDeWolf/feat-list-annotations
Issue #42 support build annotations (draft)
2 parents c23ac4a + d79aee1 commit 84d9b86

4 files changed

Lines changed: 171 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ This is an [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introd
2020
* `access_token` - Get the details for the API access token that was used to authenticate the request
2121
* `list_artifacts` - List the artifacts for a Buildkite build
2222
* `get_artifact` - Get an artifact from a Buildkite build
23+
* `list_annotations` - List the annotations for a Buildkite build
2324

2425
Example of the `get_pipeline` tool in action.
2526

internal/buildkite/annotations.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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/internal/trace"
11+
"github.com/buildkite/go-buildkite/v4"
12+
"github.com/mark3labs/mcp-go/mcp"
13+
"github.com/mark3labs/mcp-go/server"
14+
"go.opentelemetry.io/otel/attribute"
15+
)
16+
17+
// AnnotationsClient describes the subset of the Buildkite client we need for annotations.
18+
type AnnotationsClient interface {
19+
ListByBuild(ctx context.Context, org, pipelineSlug, buildNumber string, opts *buildkite.AnnotationListOptions) ([]buildkite.Annotation, *buildkite.Response, error)
20+
}
21+
22+
// ListAnnotations returns an MCP tool + handler pair that lists annotations for a build.
23+
func ListAnnotations(ctx context.Context, client AnnotationsClient) (tool mcp.Tool, handler server.ToolHandlerFunc) {
24+
return mcp.NewTool("list_annotations",
25+
mcp.WithDescription("List the annotations for a Buildkite build"),
26+
mcp.WithString("org",
27+
mcp.Required(),
28+
mcp.Description("The organization slug for the owner of the pipeline"),
29+
),
30+
mcp.WithString("pipeline_slug",
31+
mcp.Required(),
32+
mcp.Description("The slug of the pipeline"),
33+
),
34+
mcp.WithString("build_number",
35+
mcp.Required(),
36+
mcp.Description("The build number"),
37+
),
38+
withPagination(),
39+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
40+
Title: "List Annotations",
41+
ReadOnlyHint: mcp.ToBoolPtr(true),
42+
}),
43+
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
44+
ctx, span := trace.Start(ctx, "buildkite.ListAnnotations")
45+
defer span.End()
46+
47+
org, err := request.RequireString("org")
48+
if err != nil {
49+
return mcp.NewToolResultError(err.Error()), nil
50+
}
51+
52+
pipelineSlug, err := request.RequireString("pipeline_slug")
53+
if err != nil {
54+
return mcp.NewToolResultError(err.Error()), nil
55+
}
56+
57+
buildNumber, err := request.RequireString("build_number")
58+
if err != nil {
59+
return mcp.NewToolResultError(err.Error()), nil
60+
}
61+
62+
paginationParams, err := optionalPaginationParams(request)
63+
if err != nil {
64+
return mcp.NewToolResultError(err.Error()), nil
65+
}
66+
67+
span.SetAttributes(
68+
attribute.String("org", org),
69+
attribute.String("pipeline_slug", pipelineSlug),
70+
attribute.String("build_number", buildNumber),
71+
attribute.Int("page", paginationParams.Page),
72+
attribute.Int("per_page", paginationParams.PerPage),
73+
)
74+
75+
annotations, resp, err := client.ListByBuild(ctx, org, pipelineSlug, buildNumber, &buildkite.AnnotationListOptions{
76+
ListOptions: paginationParams,
77+
})
78+
if err != nil {
79+
return mcp.NewToolResultError(err.Error()), nil
80+
}
81+
82+
if resp.StatusCode != http.StatusOK {
83+
body, err := io.ReadAll(resp.Body)
84+
if err != nil {
85+
return nil, fmt.Errorf("failed to read response body: %w", err)
86+
}
87+
return mcp.NewToolResultError(fmt.Sprintf("failed to list annotations: %s", string(body))), nil
88+
}
89+
90+
result := PaginatedResult[buildkite.Annotation]{
91+
Items: annotations,
92+
Headers: map[string]string{
93+
"Link": resp.Header.Get("Link"),
94+
},
95+
}
96+
97+
r, err := json.Marshal(&result)
98+
if err != nil {
99+
return nil, fmt.Errorf("failed to marshal annotations: %w", err)
100+
}
101+
102+
return mcp.NewToolResultText(string(r)), nil
103+
}
104+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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(ctx, client)
51+
assert.NotNil(tool)
52+
assert.NotNil(handler)
53+
request := createMCPRequest(t, map[string]any{
54+
"org": "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.Equal(`{"headers":{"Link":""},"items":[{"id":"1","body_html":"Test annotation 1"},{"id":"2","body_html":"Test annotation 2"}]}`, textContent.Text)
63+
}

internal/commands/mcp.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,5 +70,8 @@ func BuildkiteTools(ctx context.Context, client *gobuildkite.Client) []server.Se
7070
tools = addTool(buildkite.ListArtifacts(ctx, clientAdapter))
7171
tools = addTool(buildkite.GetArtifact(ctx, clientAdapter))
7272

73+
// Annotation tools
74+
tools = addTool(buildkite.ListAnnotations(ctx, client.Annotations))
75+
7376
return tools
7477
}

0 commit comments

Comments
 (0)