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 11 commits into
base: main
Choose a base branch
from
6 changes: 4 additions & 2 deletions cmd/mcp-grafana/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ type disabledTools struct {

search, datasource, incident,
prometheus, loki, alerting,
dashboard, oncall, sift bool
dashboard, oncall, asserts, sift bool
}

// Configuration for the Grafana client.
Expand All @@ -44,7 +44,7 @@ type grafanaConfig struct {
}

func (dt *disabledTools) addFlags() {
flag.StringVar(&dt.enabledTools, "enabled-tools", "search,datasource,incident,prometheus,loki,alerting,dashboard,oncall", "A comma separated list of tools enabled for this server. Can be overwritten entirely or by disabling specific components, e.g. --disable-search.")
flag.StringVar(&dt.enabledTools, "enabled-tools", "search,datasource,incident,prometheus,loki,alerting,dashboard,oncall,asserts,sift", "A comma separated list of tools enabled for this server. Can be overwritten entirely or by disabling specific components, e.g. --disable-search.")

flag.BoolVar(&dt.search, "disable-search", false, "Disable search tools")
flag.BoolVar(&dt.datasource, "disable-datasource", false, "Disable datasource tools")
Expand All @@ -54,6 +54,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")
flag.BoolVar(&dt.sift, "disable-sift", false, "Disable sift tools")
}

Expand All @@ -71,6 +72,7 @@ func (dt *disabledTools) addTools(s *server.MCPServer) {
maybeAddTools(s, tools.AddAlertingTools, enabledTools, dt.alerting, "alerting")
maybeAddTools(s, tools.AddDashboardTools, enabledTools, dt.dashboard, "dashboard")
maybeAddTools(s, tools.AddOnCallTools, enabledTools, dt.oncall, "oncall")
maybeAddTools(s, tools.AddAssertsTools, enabledTools, dt.asserts, "asserts")
maybeAddTools(s, tools.AddSiftTools, enabledTools, dt.sift, "sift")
}

Expand Down
146 changes: 146 additions & 0 deletions tools/asserts.go
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could you un-export all of the structs (other than GetAssertionsParams and GetAssertions) so we keep the tools package clean when imported? 🙏

Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package tools

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"

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 {
StartRFC3339 string `json:"startRfc3339" jsonschema:"required,description=The start time in RFC3339 format"`
EndRFC3339 string `json:"endRfc3339" jsonschema:"required,description=The end time in RFC3339 format"`
EntityType string `json:"entityType" jsonschema:"description=The type of the entity to list (e.g. Service\\, Node\\, Pod\\, etc.)"`
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"`
}

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"`
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)
}

startTime, err := time.Parse(time.RFC3339, args.StartRFC3339)
if err != nil {
return "", fmt.Errorf("parsing start time: %w", err)
}

endTime, err := time.Parse(time.RFC3339, args.EndRFC3339)
if err != nil {
return "", fmt.Errorf("parsing end time: %w", err)
}

// Create request body
reqBody := requestBody{
StartTime: startTime.UnixMilli(),
EndTime: endTime.UnixMilli(),
EntityKeys: []entity{
{
Name: args.EntityName,
Type: args.EntityType,
Scope: scope{
Env: args.Env,
Site: args.Site,
Namespace: args.Namespace,
},
},
},
SuggestionSrcEntities: []entity{},
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)
}

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

package tools

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

mcpgrafana "github.com/grafana/mcp-grafana"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func setupMockAssertsServer(handler http.HandlerFunc) (*httptest.Server, context.Context) {
server := httptest.NewServer(handler)
ctx := context.Background()
ctx = mcpgrafana.WithGrafanaURL(ctx, server.URL)
ctx = mcpgrafana.WithGrafanaAPIKey(ctx, "test-api-key")
return server, ctx
}

func TestAssertTools(t *testing.T) {
t.Run("get assertions", func(t *testing.T) {
server, ctx := setupMockAssertsServer(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/plugins/grafana-asserts-app/resources/asserts/api-server/v1/assertions/llm-summary", r.URL.Path)
require.Equal(t, "Bearer test-api-key", r.Header.Get("Authorization"))

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte(`{"summary": "test summary"}`))
require.NoError(t, err)
})
defer server.Close()

result, err := getAssertions(ctx, GetAssertionsParams{
StartRFC3339: "2025-04-23T10:00:00Z",
EndRFC3339: "2025-04-23T16:00:00Z",
EntityType: "Service",
EntityName: "mongodb",
Env: "asserts-demo",
Site: "app",
Namespace: "robot-shop",
})
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, `{"summary": "test summary"}`, result)
})
}