Skip to content

Commit c292396

Browse files
committed
Add MCP list services tool
Signed-off-by: Slok Bot <slok69+slokbot@gmail.com>
1 parent 56e7162 commit c292396

12 files changed

Lines changed: 375 additions & 158 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}}
13+
github.com/slok/sloth/internal/http/mcp/tools: {interfaces: {SLOLister, SLOGetter, ServiceLister}}

internal/http/backend/app/service.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const (
2121

2222
type ListServicesRequest struct {
2323
FilterSearchInput string
24+
PageSize int
2425
SortMode ServiceListSortMode
2526
Cursor string
2627
}
@@ -88,7 +89,7 @@ func (a *App) ListServices(ctx context.Context, req ListServicesRequest) (*ListS
8889
}
8990

9091
// Handle pagination here for now, storage returns all.
91-
psvcs, cursors := paginateSlice(svcs, req.Cursor, 0)
92+
psvcs, cursors := paginateSlice(svcs, req.Cursor, req.PageSize)
9293
return &ListServicesResponse{
9394
Services: psvcs,
9495
PaginationCursors: cursors,

internal/http/mcp/mcp.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
)
1717

1818
type ServiceApp interface {
19+
ListServices(ctx context.Context, req backendapp.ListServicesRequest) (*backendapp.ListServicesResponse, error)
1920
ListSLOs(ctx context.Context, req backendapp.ListSLOsRequest) (*backendapp.ListSLOsResponse, error)
2021
GetSLO(ctx context.Context, req backendapp.GetSLORequest) (*backendapp.GetSLOResponse, error)
2122
}
@@ -50,13 +51,16 @@ func New(cfg Config) (http.Handler, error) {
5051
}, nil)
5152
registeredTools := 0
5253

53-
contextTool, contextToolHandler := tools.NewContextTool()
54+
contextTool, contextToolHandler := tools.NewContextTool(cfg.Logger.WithValues(log.Kv{"tool": "context"}))
5455
registerTool(server, contextTool, contextToolHandler)
5556
registeredTools++
56-
listSLOsTool, listSLOsToolHandler := tools.NewListSLOsTool(cfg.ServiceApp)
57+
listSLOsTool, listSLOsToolHandler := tools.NewListSLOsTool(cfg.ServiceApp, cfg.Logger.WithValues(log.Kv{"tool": "list_slos"}))
5758
registerTool(server, listSLOsTool, listSLOsToolHandler)
5859
registeredTools++
59-
getSLOTool, getSLOToolHandler := tools.NewGetSLOTool(cfg.ServiceApp)
60+
listServicesTool, listServicesToolHandler := tools.NewListServicesTool(cfg.ServiceApp, cfg.Logger.WithValues(log.Kv{"tool": "list_services"}))
61+
registerTool(server, listServicesTool, listServicesToolHandler)
62+
registeredTools++
63+
getSLOTool, getSLOToolHandler := tools.NewGetSLOTool(cfg.ServiceApp, cfg.Logger.WithValues(log.Kv{"tool": "get_slo"}))
6064
registerTool(server, getSLOTool, getSLOToolHandler)
6165
registeredTools++
6266

internal/http/mcp/tools/context.go

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,39 @@ package tools
22

33
import (
44
"context"
5+
"fmt"
56

67
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
78
"github.com/slok/sloth/internal/info"
9+
"github.com/slok/sloth/internal/log"
810
)
911

10-
func NewContextTool() (*sdkmcp.Tool, sdkmcp.ToolHandlerFor[contextToolInput, contextToolOutput]) {
12+
func NewContextTool(logger log.Logger) (*sdkmcp.Tool, sdkmcp.ToolHandlerFor[ContextToolInput, ContextToolOutput]) {
13+
if logger == nil {
14+
logger = log.Noop
15+
}
16+
1117
return &sdkmcp.Tool{
1218
Name: "context",
13-
Description: "Get context about Sloth and its SLO framework.",
19+
Description: "Get context about Sloth and its SLO framework. Returns the running Sloth version and a description of what Sloth does.",
1420
Annotations: &sdkmcp.ToolAnnotations{
1521
ReadOnlyHint: true,
1622
},
17-
}, newContextToolHandler()
23+
}, newContextToolHandler(logger)
1824
}
1925

20-
type contextToolInput struct{}
26+
type ContextToolInput struct{}
2127

22-
type contextToolOutput struct {
28+
type ContextToolOutput struct {
2329
Version string `json:"version" jsonschema:"the running Sloth version"`
2430
Description string `json:"description" jsonschema:"what Sloth is and what it does"`
2531
}
2632

27-
func newContextToolHandler() sdkmcp.ToolHandlerFor[contextToolInput, contextToolOutput] {
28-
return func(_ context.Context, _ *sdkmcp.CallToolRequest, _ contextToolInput) (*sdkmcp.CallToolResult, contextToolOutput, error) {
29-
return nil, contextToolOutput{
33+
func newContextToolHandler(logger log.Logger) sdkmcp.ToolHandlerFor[ContextToolInput, ContextToolOutput] {
34+
return func(_ context.Context, _ *sdkmcp.CallToolRequest, input ContextToolInput) (*sdkmcp.CallToolResult, ContextToolOutput, error) {
35+
logger.WithValues(log.Kv{"input": fmt.Sprintf("%+v", input)}).Debugf("MCP tool called")
36+
37+
return nil, ContextToolOutput{
3038
Version: info.Version,
3139
Description: "Sloth is a Prometheus SLO framework that helps teams define service level objectives and creates a uniform, standardized layer of low-level Prometheus rules to implement SLOs, including the recording and alerting rules required to measure them.",
3240
}, nil

internal/http/mcp/tools/context_test.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
1-
package tools
1+
package tools_test
22

33
import (
44
"context"
55
"testing"
66

7+
"github.com/slok/sloth/internal/http/mcp/tools"
8+
"github.com/slok/sloth/internal/log"
79
"github.com/stretchr/testify/assert"
810
"github.com/stretchr/testify/require"
911
)
1012

1113
func TestNewContextTool(t *testing.T) {
1214
tests := map[string]struct {
1315
expErr bool
14-
expResp contextToolOutput
16+
expResp tools.ContextToolOutput
1517
}{
1618
"Tool should expose metadata and static context payload.": {
17-
expResp: contextToolOutput{
19+
expResp: tools.ContextToolOutput{
1820
Version: "dev",
1921
Description: "Sloth is a Prometheus SLO framework that helps teams define service level objectives and creates a uniform, standardized layer of low-level Prometheus rules to implement SLOs, including the recording and alerting rules required to measure them.",
2022
},
@@ -23,13 +25,13 @@ func TestNewContextTool(t *testing.T) {
2325

2426
for name, test := range tests {
2527
t.Run(name, func(t *testing.T) {
26-
tool, handler := NewContextTool()
28+
tool, handler := tools.NewContextTool(log.Noop)
2729

2830
require.NotNil(t, tool)
2931
assert.Equal(t, "context", tool.Name)
30-
assert.Equal(t, "Get context about Sloth and its SLO framework.", tool.Description)
32+
assert.Equal(t, "Get context about Sloth and its SLO framework. Returns the running Sloth version and a description of what Sloth does.", tool.Description)
3133

32-
result, gotResp, err := handler(context.Background(), nil, contextToolInput{})
34+
result, gotResp, err := handler(context.Background(), nil, tools.ContextToolInput{})
3335
assert.Nil(t, result)
3436

3537
if test.expErr {

internal/http/mcp/tools/get_slo.go

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,47 @@ package tools
22

33
import (
44
"context"
5+
"fmt"
56

67
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
78

89
backendapp "github.com/slok/sloth/internal/http/backend/app"
10+
"github.com/slok/sloth/internal/log"
911
)
1012

1113
type SLOGetter interface {
1214
GetSLO(ctx context.Context, req backendapp.GetSLORequest) (*backendapp.GetSLOResponse, error)
1315
}
1416

15-
func NewGetSLOTool(app SLOGetter) (*sdkmcp.Tool, sdkmcp.ToolHandlerFor[getSLOToolInput, getSLOToolOutput]) {
17+
func NewGetSLOTool(app SLOGetter, logger log.Logger) (*sdkmcp.Tool, sdkmcp.ToolHandlerFor[GetSLOToolInput, GetSLOToolOutput]) {
18+
if logger == nil {
19+
logger = log.Noop
20+
}
21+
1622
return &sdkmcp.Tool{
1723
Name: "get_slo",
18-
Description: "Get a single SLO with its current budget and alert status.",
24+
Description: "Get a single SLO with its metadata, current budget burn, consumed budget in the window, and firing alert status.",
1925
Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true},
20-
}, newGetSLOToolHandler(app)
26+
}, newGetSLOToolHandler(app, logger)
2127
}
2228

23-
type getSLOToolInput struct {
29+
type GetSLOToolInput struct {
2430
SLOID string `json:"slo_id" jsonschema:"required,The SLO ID to retrieve"`
2531
}
2632

27-
type getSLOToolOutput struct {
28-
SLO listSLOsToolOutputItem `json:"slo" jsonschema:"the requested SLO with its current status"`
33+
type GetSLOToolOutput struct {
34+
SLO ListSLOsToolOutputItem `json:"slo" jsonschema:"the requested SLO with its current status"`
2935
}
3036

31-
func newGetSLOToolHandler(app SLOGetter) sdkmcp.ToolHandlerFor[getSLOToolInput, getSLOToolOutput] {
32-
return func(ctx context.Context, _ *sdkmcp.CallToolRequest, input getSLOToolInput) (*sdkmcp.CallToolResult, getSLOToolOutput, error) {
37+
func newGetSLOToolHandler(app SLOGetter, logger log.Logger) sdkmcp.ToolHandlerFor[GetSLOToolInput, GetSLOToolOutput] {
38+
return func(ctx context.Context, _ *sdkmcp.CallToolRequest, input GetSLOToolInput) (*sdkmcp.CallToolResult, GetSLOToolOutput, error) {
39+
logger.WithValues(log.Kv{"input": fmt.Sprintf("%+v", input)}).Debugf("MCP tool called")
40+
3341
resp, err := app.GetSLO(ctx, backendapp.GetSLORequest{SLOID: input.SLOID})
3442
if err != nil {
35-
return nil, getSLOToolOutput{}, err
43+
return nil, GetSLOToolOutput{}, err
3644
}
3745

38-
return nil, getSLOToolOutput{SLO: mapRealTimeSLOToToolOutputItem(resp.SLO)}, nil
46+
return nil, GetSLOToolOutput{SLO: mapRealTimeSLOToToolOutputItem(resp.SLO)}, nil
3947
}
4048
}

internal/http/mcp/tools/get_slo_test.go

Lines changed: 10 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package tools
1+
package tools_test
22

33
import (
44
"context"
@@ -8,65 +8,30 @@ import (
88

99
backendapp "github.com/slok/sloth/internal/http/backend/app"
1010
"github.com/slok/sloth/internal/http/backend/model"
11+
"github.com/slok/sloth/internal/http/mcp/tools"
1112
"github.com/slok/sloth/internal/http/mcp/tools/toolsmock"
13+
"github.com/slok/sloth/internal/log"
1214
"github.com/stretchr/testify/assert"
1315
"github.com/stretchr/testify/mock"
1416
"github.com/stretchr/testify/require"
1517
)
1618

1719
func TestNewGetSLOTool(t *testing.T) {
1820
tests := map[string]struct {
19-
input getSLOToolInput
21+
input tools.GetSLOToolInput
2022
mock func(m *toolsmock.SLOGetter)
2123
expErr bool
22-
expResp getSLOToolOutput
24+
expResp tools.GetSLOToolOutput
2325
}{
2426
"It should map the backend request and response.": {
25-
input: getSLOToolInput{SLOID: "slo-id"},
27+
input: tools.GetSLOToolInput{SLOID: "slo-id"},
2628
mock: func(m *toolsmock.SLOGetter) {
27-
expReq := backendapp.GetSLORequest{SLOID: "slo-id"}
28-
m.On("GetSLO", mock.Anything, expReq).Once().Return(&backendapp.GetSLOResponse{
29-
SLO: backendapp.RealTimeSLODetails{
30-
SLO: model.SLO{
31-
ID: "slo-id",
32-
SlothID: "sloth-slo-id",
33-
Name: "availability",
34-
ServiceID: "checkout",
35-
Objective: 99.9,
36-
PeriodDuration: 30 * 24 * time.Hour,
37-
IsGrouped: true,
38-
GroupLabels: map[string]string{"region": "eu-west-1"},
39-
},
40-
Budget: model.SLOBudgetDetails{
41-
BurningBudgetPercent: 123.4,
42-
BurnedBudgetWindowPercent: 77.7,
43-
},
44-
Alerts: model.SLOAlerts{
45-
FiringPage: &model.Alert{Name: "PageAlert"},
46-
FiringWarning: &model.Alert{Name: "WarnAlert"},
47-
},
48-
},
49-
}, nil)
29+
m.On("GetSLO", mock.Anything, backendapp.GetSLORequest{SLOID: "slo-id"}).Once().Return(&backendapp.GetSLOResponse{SLO: backendapp.RealTimeSLODetails{SLO: model.SLO{ID: "slo-id", SlothID: "sloth-slo-id", Name: "availability", ServiceID: "checkout", Objective: 99.9, PeriodDuration: 30 * 24 * time.Hour, IsGrouped: true, GroupLabels: map[string]string{"region": "eu-west-1"}}, Budget: model.SLOBudgetDetails{BurningBudgetPercent: 123.4, BurnedBudgetWindowPercent: 77.7}, Alerts: model.SLOAlerts{FiringPage: &model.Alert{Name: "PageAlert"}, FiringWarning: &model.Alert{Name: "WarnAlert"}}}}, nil)
5030
},
51-
expResp: getSLOToolOutput{SLO: listSLOsToolOutputItem{
52-
ID: "slo-id",
53-
SlothID: "sloth-slo-id",
54-
Name: "availability",
55-
ServiceID: "checkout",
56-
Objective: 99.9,
57-
Period: "720h0m0s",
58-
IsGrouped: true,
59-
GroupLabels: map[string]string{"region": "eu-west-1"},
60-
BurningBudgetPercent: 123.4,
61-
BurnedBudgetWindowPercent: 77.7,
62-
HasPageAlert: true,
63-
PageAlertName: "PageAlert",
64-
HasWarningAlert: true,
65-
WarningAlertName: "WarnAlert",
66-
}},
31+
expResp: tools.GetSLOToolOutput{SLO: tools.ListSLOsToolOutputItem{ID: "slo-id", SlothID: "sloth-slo-id", Name: "availability", ServiceID: "checkout", Objective: 99.9, Period: "720h0m0s", IsGrouped: true, GroupLabels: map[string]string{"region": "eu-west-1"}, BurningBudgetPercent: 123.4, BurnedBudgetWindowPercent: 77.7, HasPageAlert: true, PageAlertName: "PageAlert", HasWarningAlert: true, WarningAlertName: "WarnAlert"}},
6732
},
6833
"Having a backend error should fail.": {
69-
input: getSLOToolInput{SLOID: "slo-id"},
34+
input: tools.GetSLOToolInput{SLOID: "slo-id"},
7035
mock: func(m *toolsmock.SLOGetter) {
7136
m.On("GetSLO", mock.Anything, backendapp.GetSLORequest{SLOID: "slo-id"}).Once().Return(nil, fmt.Errorf("something wrong"))
7237
},
@@ -81,12 +46,9 @@ func TestNewGetSLOTool(t *testing.T) {
8146
test.mock(m)
8247
}
8348

84-
tool, handler := NewGetSLOTool(m)
49+
tool, handler := tools.NewGetSLOTool(m, log.Noop)
8550
require.NotNil(t, tool)
8651
assert.Equal(t, "get_slo", tool.Name)
87-
require.NotNil(t, tool.Annotations)
88-
assert.True(t, tool.Annotations.ReadOnlyHint)
89-
9052
result, gotResp, err := handler(context.Background(), nil, test.input)
9153
assert.Nil(t, result)
9254

0 commit comments

Comments
 (0)