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

Merged
merged 15 commits into from
Apr 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,6 @@ jobs:
env:
GRAFANA_URL: ${{ vars.CLOUD_GRAFANA_URL }}
GRAFANA_API_KEY: ${{ secrets.CLOUD_GRAFANA_API_KEY }}
ASSERTS_GRAFANA_URL: ${{ vars.ASSERTS_GRAFANA_URL }}
ASSERTS_GRAFANA_API_KEY: ${{ secrets.ASSERTS_GRAFANA_API_KEY }}
run: make test-cloud
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
141 changes: 141 additions & 0 deletions tools/asserts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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 {
StartTime time.Time `json:"startTime" jsonschema:"required,description=The start time in RFC3339 format"`
EndTime time.Time `json:"endTime" 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,omitempty" jsonschema:"description=The env of the entity to list"`
Site string `json:"site,omitempty" jsonschema:"description=The site of the entity to list"`
Namespace string `json:"namespace,omitempty" jsonschema:"description=The namespace of the entity to list"`
}

type scope struct {
Env string `json:"env,omitempty"`
Site string `json:"site,omitempty"`
Namespace string `json:"namespace,omitempty"`
}

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

// Create request body
reqBody := requestBody{
StartTime: args.StartTime.UnixMilli(),
EndTime: args.EndTime.UnixMilli(),
EntityKeys: []entity{
{
Name: args.EntityName,
Type: args.EntityType,
Scope: scope{},
},
},
SuggestionSrcEntities: []entity{},
AlertCategories: []string{"saturation", "amend", "anomaly", "failure", "error"},
}

if args.Env != "" {
reqBody.EntityKeys[0].Scope.Env = args.Env
}
if args.Site != "" {
reqBody.EntityKeys[0].Scope.Site = args.Site
}
if args.Namespace != "" {
reqBody.EntityKeys[0].Scope.Namespace = args.Namespace
}

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)
}
45 changes: 45 additions & 0 deletions tools/asserts_cloud_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//go:build cloud
// +build cloud

// This file contains cloud integration tests that run against a dedicated test instance
// connected to a Grafana instance at (ASSERTS_GRAFANA_URL, ASSERTS_GRAFANA_API_KEY).
// These tests expect this configuration to exist and will skip if the required
// environment variables are not set.

package tools

import (
"testing"
"time"

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

func TestAssertsCloudIntegration(t *testing.T) {
ctx := createCloudTestContext(t, "Asserts", "ASSERTS_GRAFANA_URL", "ASSERTS_GRAFANA_API_KEY")

t.Run("get assertions", func(t *testing.T) {
// Set up time range for the last hour
endTime := time.Now()
startTime := endTime.Add(-24 * time.Hour)

// Test parameters for a known service in the environment
params := GetAssertionsParams{
StartTime: startTime,
EndTime: endTime,
EntityType: "Service", // Adjust these values based on your actual environment
EntityName: "model-builder",
Env: "dev-us-central-0",
Namespace: "asserts",
}

// Get assertions from the real Grafana instance
result, err := getAssertions(ctx, params)
require.NoError(t, err, "Failed to get assertions from Grafana")
assert.NotEmpty(t, result, "Expected non-empty assertions result")

// Basic validation of the response structure
assert.Contains(t, result, "summaries", "Response should contain a summaries field")
})
}
125 changes: 125 additions & 0 deletions tools/asserts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
//go:build unit
// +build unit

package tools

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"

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) {
startTime := time.Date(2025, 4, 23, 10, 0, 0, 0, time.UTC)
endTime := time.Date(2025, 4, 23, 11, 0, 0, 0, time.UTC)
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"))

var requestBody map[string]interface{}
err := json.NewDecoder(r.Body).Decode(&requestBody)
require.NoError(t, err)

expectedBody := map[string]interface{}{
"startTime": float64(startTime.UnixMilli()),
"endTime": float64(endTime.UnixMilli()),
"entityKeys": []interface{}{
map[string]interface{}{
"type": "Service",
"name": "mongodb",
"scope": map[string]interface{}{
"env": "asserts-demo",
"site": "app",
"namespace": "robot-shop",
},
},
},
"suggestionSrcEntities": []interface{}{},
"alertCategories": []interface{}{"saturation", "amend", "anomaly", "failure", "error"},
}
require.Equal(t, expectedBody, requestBody)

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{
StartTime: startTime,
EndTime: endTime,
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)
})

t.Run("get assertions with no site and namespace", func(t *testing.T) {
startTime := time.Date(2025, 4, 23, 10, 0, 0, 0, time.UTC)
endTime := time.Date(2025, 4, 23, 11, 0, 0, 0, time.UTC)
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"))

var requestBody map[string]interface{}
err := json.NewDecoder(r.Body).Decode(&requestBody)
require.NoError(t, err)

expectedBody := map[string]interface{}{
"startTime": float64(startTime.UnixMilli()),
"endTime": float64(endTime.UnixMilli()),
"entityKeys": []interface{}{
map[string]interface{}{
"type": "Service",
"name": "mongodb",
"scope": map[string]interface{}{
"env": "asserts-demo",
},
},
},
"suggestionSrcEntities": []interface{}{},
"alertCategories": []interface{}{"saturation", "amend", "anomaly", "failure", "error"},
}
require.Equal(t, expectedBody, requestBody)

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{
StartTime: startTime,
EndTime: endTime,
EntityType: "Service",
EntityName: "mongodb",
Env: "asserts-demo",
})
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, `{"summary": "test summary"}`, result)
})
}
11 changes: 6 additions & 5 deletions tools/cloud_testing_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,16 @@ import (
// createCloudTestContext creates a context with Grafana URL and API key for cloud integration tests.
// The test will be skipped if required environment variables are not set.
// testName is used to customize the skip message (e.g. "OnCall", "Sift", "Incident")
func createCloudTestContext(t *testing.T, testName string) context.Context {
grafanaURL := os.Getenv("GRAFANA_URL")
// urlEnv and apiKeyEnv specify the environment variable names for the Grafana URL and API key.
func createCloudTestContext(t *testing.T, testName, urlEnv, apiKeyEnv string) context.Context {
grafanaURL := os.Getenv(urlEnv)
if grafanaURL == "" {
t.Skipf("GRAFANA_URL environment variable not set, skipping cloud %s integration tests", testName)
t.Skipf("%s environment variable not set, skipping cloud %s integration tests", urlEnv, testName)
}

grafanaApiKey := os.Getenv("GRAFANA_API_KEY")
grafanaApiKey := os.Getenv(apiKeyEnv)
if grafanaApiKey == "" {
t.Skipf("GRAFANA_API_KEY environment variable not set, skipping cloud %s integration tests", testName)
t.Skipf("%s environment variable not set, skipping cloud %s integration tests", apiKeyEnv, testName)
}

ctx := context.Background()
Expand Down
4 changes: 2 additions & 2 deletions tools/incident_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import (

func TestCloudIncidentTools(t *testing.T) {
t.Run("list incidents", func(t *testing.T) {
ctx := createCloudTestContext(t, "Incident")
ctx := createCloudTestContext(t, "Incident", "GRAFANA_URL", "GRAFANA_API_KEY")
ctx = mcpgrafana.ExtractIncidentClientFromEnv(ctx)

result, err := listIncidents(ctx, ListIncidentsParams{
Expand All @@ -34,7 +34,7 @@ func TestCloudIncidentTools(t *testing.T) {
})

t.Run("get incident by ID", func(t *testing.T) {
ctx := createCloudTestContext(t, "Incident")
ctx := createCloudTestContext(t, "Incident", "GRAFANA_URL", "GRAFANA_API_KEY")
ctx = mcpgrafana.ExtractIncidentClientFromEnv(ctx)
result, err := getIncident(ctx, GetIncidentParams{
ID: "1",
Expand Down
Loading