Skip to content

First Asserts tool #105

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
4 changes: 3 additions & 1 deletion cmd/mcp-grafana/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func maybeAddTools(s *server.MCPServer, tf func(*server.MCPServer), disable bool
type disabledTools struct {
search, datasource, incident,
prometheus, loki, alerting,
dashboard, oncall bool
dashboard, oncall, asserts bool
}

// Configuration for the Grafana client.
Expand All @@ -43,6 +43,7 @@ func (dt *disabledTools) addFlags() {
flag.BoolVar(&dt.alerting, "disable-alerting", false, "Disable alerting tools")
flag.BoolVar(&dt.dashboard, "disable-dashboard", false, "Disable dashboard tools")
flag.BoolVar(&dt.oncall, "disable-oncall", false, "Disable oncall tools")
flag.BoolVar(&dt.asserts, "disable-asserts", false, "Disable asserts tools")
}

func (gc *grafanaConfig) addFlags() {
Expand All @@ -58,6 +59,7 @@ func (dt *disabledTools) addTools(s *server.MCPServer) {
maybeAddTools(s, tools.AddAlertingTools, dt.alerting, "alerting")
maybeAddTools(s, tools.AddDashboardTools, dt.dashboard, "dashboard")
maybeAddTools(s, tools.AddOnCallTools, dt.oncall, "oncall")
maybeAddTools(s, tools.AddAssertsTools, dt.asserts, "asserts")
}

func newServer(dt disabledTools) *server.MCPServer {
Expand Down
135 changes: 135 additions & 0 deletions tools/asserts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package tools

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
mcpgrafana "github.com/grafana/mcp-grafana"
"github.com/mark3labs/mcp-go/server"
)

func newAssertsClient(ctx context.Context) (*Client, error) {
grafanaURL, apiKey := mcpgrafana.GrafanaURLFromContext(ctx), mcpgrafana.GrafanaAPIKeyFromContext(ctx)
url := fmt.Sprintf("%s/api/plugins/grafana-asserts-app/resources/asserts/api-server", strings.TrimRight(grafanaURL, "/"))

client := &http.Client{
Transport: &authRoundTripper{
apiKey: apiKey,
underlying: http.DefaultTransport,
},
}

return &Client{
httpClient: client,
baseURL: url,
}, nil
}

type GetAssertionsParams struct {
StartTime int64 `json:"startTime" jsonschema:"description=The start time of the assertion status in RFC3339 format"`
EndTime int64 `json:"endTime" jsonschema:"description=The end time of the assertion status in RFC3339 format"`
EntityType string `json:"entityType" jsonschema:"description=The type of the entity to list"`
EntityName string `json:"entityName" jsonschema:"description=The name of the entity to list"`
Env string `json:"env" jsonschema:"description=The env of the entity to list"`
Site string `json:"site" jsonschema:"description=The site of the entity to list"`
Namespace string `json:"namespace" jsonschema:"description=The namespace of the entity to list"`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for each of these string types, is the model expected to know the possible options in advance? If it's a closed set (e.g. entity type) we should provide a list of options in the description really; for the others, perhaps we should tell the LLM how to obtain valid values either in the field description or tool description.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can supply a list of know types. This will help LLM.

}

type Scope struct {
Env string `json:"env"`
Site string `json:"site"`
Namespace string `json:"namespace"`
}

type Entity struct {
Name string `json:"name"`
Type string `json:"type"`
Scope Scope `json:"scope"`
}

type RequestBody struct {
StartTime int64 `json:"startTime"`
EndTime int64 `json:"endTime"`
EntityKeys []Entity `json:"entityKeys"`
SuggestionSrcEntities []Entity `json:"suggestionSrcEntities"`
GroupAssertions bool `json:"groupAssertions"`
AlertCategories []string `json:"alertCategories"`
}
func (c *Client) fetchAssertsData(ctx context.Context, urlPath string, method string, reqBody any) (string, error) {
jsonData, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("failed to marshal request body: %w", err)
}

req, err := http.NewRequestWithContext(ctx, method, c.baseURL+urlPath, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")

resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
}

return string(body), nil
}

func getAssertions(ctx context.Context, args GetAssertionsParams) (string, error) {
client, err := newAssertsClient(ctx)
if err != nil {
return "", fmt.Errorf("failed to create Asserts client: %w", err)
}

// Create request body
reqBody := RequestBody{
StartTime: args.StartTime * 1000,
EndTime: args.EndTime * 1000,
EntityKeys: []Entity{
{
Name: args.EntityName,
Type: args.EntityType,
Scope: Scope{
Env: args.Env,
Site: args.Site,
Namespace: args.Namespace,
},
},
},
SuggestionSrcEntities: []Entity{},
GroupAssertions: true,
AlertCategories: []string{"Saturation", "Amend", "Anomaly", "Failure", "Error"},
}

data, err := client.fetchAssertsData(ctx, "/v1/assertions/llm-summary", "POST", reqBody)
if err != nil {
return "", fmt.Errorf("failed to fetch data: %w", err)
}

return data, nil
}

var GetAssertions = mcpgrafana.MustTool(
"get_assertions",
"Get assertion summary for a given entity with its type, name, env, site, namespace, and a time range",
getAssertions,
)

func AddAssertsTools(mcp *server.MCPServer) {
GetAssertions.Register(mcp)
}

28 changes: 28 additions & 0 deletions tools/asserts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//go:build unit
// +build unit

package tools

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAssertTools(t *testing.T) {
t.Run("get assertions", func(t *testing.T) {
ctx := newTestContext()

Check failure on line 15 in tools/asserts_test.go

View workflow job for this annotation

GitHub Actions / Test Unit

undefined: newTestContext
result, err := getAssertions(ctx, GetAssertionsParams{
EntityName: "test",
EntityType: "test",
Env: "test",
Site: "test",
Namespace: "test",
StartTime: 1713571200,
EndTime: 1713657600,
})
require.NoError(t, err)
assert.NotNil(t, result)
})
}
Loading