Skip to content

Commit 8a3be4f

Browse files
authored
Merge pull request #825 from slok/slok/add-sli-availability-tool
Add MCP SLI availability tool
2 parents 636daa0 + 07b31fb commit 8a3be4f

5 files changed

Lines changed: 313 additions & 30 deletions

File tree

.mockery.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ packages:
1010
github.com/slok/sloth/internal/http/backend/storage: {interfaces: {SLOGetter, ServiceGetter}}
1111
github.com/slok/sloth/internal/http/backend/storage/prometheus: {interfaces: {PrometheusAPIClient}}
1212
github.com/slok/sloth/internal/http/ui: {interfaces: {ServiceApp}}
13-
github.com/slok/sloth/internal/http/mcp/tools: {interfaces: {SLOLister, SLOGetter, ServiceLister, BurnedBudgetRangeLister}}
13+
github.com/slok/sloth/internal/http/mcp/tools: {interfaces: {SLOLister, SLOGetter, ServiceLister, BurnedBudgetRangeLister, SLIAvailabilityRangeLister}}

internal/http/mcp/mcp.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type ServiceApp interface {
1919
ListServices(ctx context.Context, req backendapp.ListServicesRequest) (*backendapp.ListServicesResponse, error)
2020
ListSLOs(ctx context.Context, req backendapp.ListSLOsRequest) (*backendapp.ListSLOsResponse, error)
2121
GetSLO(ctx context.Context, req backendapp.GetSLORequest) (*backendapp.GetSLOResponse, error)
22+
ListSLIAvailabilityRange(ctx context.Context, req backendapp.ListSLIAvailabilityRangeRequest) (*backendapp.ListSLIAvailabilityRangeResponse, error)
2223
ListBurnedBudgetRange(ctx context.Context, req backendapp.ListBurnedBudgetRangeRequest) (*backendapp.ListBurnedBudgetRangeResponse, error)
2324
}
2425

@@ -67,6 +68,9 @@ func New(cfg Config) (http.Handler, error) {
6768
getBurnedBudgetRangeTool, getBurnedBudgetRangeToolHandler := tools.NewGetSLOBurnedBudgetRangeTool(cfg.ServiceApp, cfg.Logger.WithValues(log.Kv{"tool": "get_slo_burned_budget_range"}))
6869
registerTool(server, getBurnedBudgetRangeTool, getBurnedBudgetRangeToolHandler)
6970
registeredTools++
71+
getSLIAvailabilityRangeTool, getSLIAvailabilityRangeToolHandler := tools.NewGetSLOSLIAvailabilityRangeTool(cfg.ServiceApp, cfg.Logger.WithValues(log.Kv{"tool": "get_slo_sli_availability_range"}))
72+
registerTool(server, getSLIAvailabilityRangeTool, getSLIAvailabilityRangeToolHandler)
73+
registeredTools++
7074

7175
cfg.Logger.WithValues(log.Kv{"tools": registeredTools}).Infof("MCP request/response handler enabled")
7276

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package tools
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strconv"
7+
"strings"
8+
"time"
9+
10+
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
11+
12+
backendapp "github.com/slok/sloth/internal/http/backend/app"
13+
"github.com/slok/sloth/internal/http/backend/model"
14+
"github.com/slok/sloth/internal/log"
15+
)
16+
17+
type SLIAvailabilityRangeLister interface {
18+
ListSLIAvailabilityRange(ctx context.Context, req backendapp.ListSLIAvailabilityRangeRequest) (*backendapp.ListSLIAvailabilityRangeResponse, error)
19+
}
20+
21+
func NewGetSLOSLIAvailabilityRangeTool(app SLIAvailabilityRangeLister, logger log.Logger) (*sdkmcp.Tool, sdkmcp.ToolHandlerFor[GetSLOSLIAvailabilityRangeToolInput, GetSLOSLIAvailabilityRangeToolOutput]) {
22+
if logger == nil {
23+
logger = log.Noop
24+
}
25+
26+
return &sdkmcp.Tool{
27+
Name: "get_slo_sli_availability_range",
28+
Description: "Get SLI availability evolution for an SLO over a time range. Returns a compressed availability series where each comma-separated entry advances by one step from start_ts, and x means the value is missing at that step.",
29+
Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true},
30+
}, newGetSLOSLIAvailabilityRangeToolHandler(app, logger)
31+
}
32+
33+
type GetSLOSLIAvailabilityRangeToolInput struct {
34+
SLOID string `json:"slo_id" jsonschema:"required,The SLO ID to retrieve"`
35+
From string `json:"from" jsonschema:"required,The RFC3339 start timestamp of the availability range"`
36+
To string `json:"to,omitempty" jsonschema:"Optional RFC3339 end timestamp of the availability range. If omitted, now is used"`
37+
}
38+
39+
type GetSLOSLIAvailabilityRangeToolOutput struct {
40+
StartTS string `json:"start_ts" jsonschema:"the RFC3339 timestamp of the first point in the series"`
41+
Step string `json:"step" jsonschema:"the fixed duration between series points"`
42+
AvailabilitySeries string `json:"availability_series" jsonschema:"comma-separated SLI availability percentage values from start_ts advancing by step. Use x when a value is missing"`
43+
}
44+
45+
func newGetSLOSLIAvailabilityRangeToolHandler(app SLIAvailabilityRangeLister, logger log.Logger) sdkmcp.ToolHandlerFor[GetSLOSLIAvailabilityRangeToolInput, GetSLOSLIAvailabilityRangeToolOutput] {
46+
return func(ctx context.Context, _ *sdkmcp.CallToolRequest, input GetSLOSLIAvailabilityRangeToolInput) (*sdkmcp.CallToolResult, GetSLOSLIAvailabilityRangeToolOutput, error) {
47+
logger.WithValues(log.Kv{"input": fmt.Sprintf("%+v", input)}).Debugf("MCP tool called")
48+
49+
from, err := time.Parse(time.RFC3339, input.From)
50+
if err != nil {
51+
return nil, GetSLOSLIAvailabilityRangeToolOutput{}, fmt.Errorf("invalid from time: %w", err)
52+
}
53+
54+
to := time.Time{}
55+
if input.To != "" {
56+
to, err = time.Parse(time.RFC3339, input.To)
57+
if err != nil {
58+
return nil, GetSLOSLIAvailabilityRangeToolOutput{}, fmt.Errorf("invalid to time: %w", err)
59+
}
60+
}
61+
62+
resp, err := app.ListSLIAvailabilityRange(ctx, backendapp.ListSLIAvailabilityRangeRequest{
63+
SLOID: input.SLOID,
64+
From: from,
65+
To: to,
66+
})
67+
if err != nil {
68+
return nil, GetSLOSLIAvailabilityRangeToolOutput{}, err
69+
}
70+
71+
startTS, step := getAvailabilitySeriesMeta(resp.AvailabilityDataPoints)
72+
73+
return nil, GetSLOSLIAvailabilityRangeToolOutput{
74+
StartTS: startTS,
75+
Step: step,
76+
AvailabilitySeries: compressAvailabilityDataPoints(resp.AvailabilityDataPoints),
77+
}, nil
78+
}
79+
}
80+
81+
func getAvailabilitySeriesMeta(dps []model.DataPoint) (startTS, step string) {
82+
if len(dps) == 0 {
83+
return "", ""
84+
}
85+
86+
startTS = dps[0].TS.Format(time.RFC3339)
87+
if len(dps) < 2 {
88+
return startTS, "0s"
89+
}
90+
91+
return startTS, dps[1].TS.Sub(dps[0].TS).String()
92+
}
93+
94+
func compressAvailabilityDataPoints(dps []model.DataPoint) string {
95+
parts := make([]string, 0, len(dps))
96+
for _, dp := range dps {
97+
if dp.Missing {
98+
parts = append(parts, "x")
99+
continue
100+
}
101+
102+
parts = append(parts, strconv.FormatFloat(dp.Value, 'f', -1, 64))
103+
}
104+
105+
return strings.Join(parts, ",")
106+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package tools_test
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
"time"
8+
9+
backendapp "github.com/slok/sloth/internal/http/backend/app"
10+
"github.com/slok/sloth/internal/http/backend/model"
11+
"github.com/slok/sloth/internal/http/mcp/tools"
12+
"github.com/slok/sloth/internal/http/mcp/tools/toolsmock"
13+
"github.com/slok/sloth/internal/log"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/mock"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
func TestNewGetSLOSLIAvailabilityRangeTool(t *testing.T) {
20+
ts1 := time.Date(2026, 5, 25, 10, 0, 0, 0, time.UTC)
21+
ts2 := time.Date(2026, 5, 25, 11, 0, 0, 0, time.UTC)
22+
23+
tests := map[string]struct {
24+
input tools.GetSLOSLIAvailabilityRangeToolInput
25+
mock func(m *toolsmock.SLIAvailabilityRangeLister)
26+
expErr bool
27+
expResp tools.GetSLOSLIAvailabilityRangeToolOutput
28+
}{
29+
"It should map the backend request and response.": {
30+
input: tools.GetSLOSLIAvailabilityRangeToolInput{SLOID: "slo-id", From: ts1.Format(time.RFC3339), To: ts2.Add(time.Hour).Format(time.RFC3339)},
31+
mock: func(m *toolsmock.SLIAvailabilityRangeLister) {
32+
expReq := backendapp.ListSLIAvailabilityRangeRequest{SLOID: "slo-id", From: ts1, To: ts2.Add(time.Hour)}
33+
m.On("ListSLIAvailabilityRange", mock.Anything, expReq).Once().Return(&backendapp.ListSLIAvailabilityRangeResponse{
34+
AvailabilityDataPoints: []model.DataPoint{{TS: ts1, Value: 99.9}, {TS: ts2, Missing: true}, {TS: ts2.Add(time.Hour), Value: 98.7}},
35+
}, nil)
36+
},
37+
expResp: tools.GetSLOSLIAvailabilityRangeToolOutput{StartTS: ts1.Format(time.RFC3339), Step: "1h0m0s", AvailabilitySeries: "99.9,x,98.7"},
38+
},
39+
"Invalid from time should fail.": {
40+
input: tools.GetSLOSLIAvailabilityRangeToolInput{SLOID: "slo-id", From: "bad-time"},
41+
expErr: true,
42+
},
43+
"Invalid to time should fail.": {
44+
input: tools.GetSLOSLIAvailabilityRangeToolInput{SLOID: "slo-id", From: ts1.Format(time.RFC3339), To: "bad-time"},
45+
expErr: true,
46+
},
47+
"Having a backend error should fail.": {
48+
input: tools.GetSLOSLIAvailabilityRangeToolInput{SLOID: "slo-id", From: ts1.Format(time.RFC3339)},
49+
mock: func(m *toolsmock.SLIAvailabilityRangeLister) {
50+
m.On("ListSLIAvailabilityRange", mock.Anything, mock.Anything).Once().Return(nil, fmt.Errorf("something wrong"))
51+
},
52+
expErr: true,
53+
},
54+
}
55+
56+
for name, test := range tests {
57+
t.Run(name, func(t *testing.T) {
58+
m := toolsmock.NewSLIAvailabilityRangeLister(t)
59+
if test.mock != nil {
60+
test.mock(m)
61+
}
62+
63+
tool, handler := tools.NewGetSLOSLIAvailabilityRangeTool(m, log.Noop)
64+
require.NotNil(t, tool)
65+
assert.Equal(t, "get_slo_sli_availability_range", tool.Name)
66+
result, gotResp, err := handler(context.Background(), nil, test.input)
67+
assert.Nil(t, result)
68+
69+
if test.expErr {
70+
assert.Error(t, err)
71+
} else {
72+
require.NoError(t, err)
73+
}
74+
75+
assert.Equal(t, test.expResp, gotResp)
76+
})
77+
}
78+
}

0 commit comments

Comments
 (0)