Skip to content

Commit 664f243

Browse files
committed
Add MCP get slo tool
Signed-off-by: Slok Bot <slok69+slokbot@gmail.com>
1 parent 92f5a38 commit 664f243

7 files changed

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

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
### Added
66

77
- `server`: Support HTTP request/response MCP with the `--mcp-enabled` and `--mcp-path` flags.
8-
- MCP: Add the `context` and `list_slos` tools.
8+
- MCP: Add the `context`, `list_slos` and `get_slo` tools.
99

1010
## [v0.16.0] - 2026-04-04
1111

internal/http/mcp/mcp.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717

1818
type ServiceApp interface {
1919
ListSLOs(ctx context.Context, req backendapp.ListSLOsRequest) (*backendapp.ListSLOsResponse, error)
20+
GetSLO(ctx context.Context, req backendapp.GetSLORequest) (*backendapp.GetSLOResponse, error)
2021
}
2122

2223
type Config struct {
@@ -55,6 +56,9 @@ func New(cfg Config) (http.Handler, error) {
5556
listSLOsTool, listSLOsToolHandler := tools.NewListSLOsTool(cfg.ServiceApp)
5657
registerTool(server, listSLOsTool, listSLOsToolHandler)
5758
registeredTools++
59+
getSLOTool, getSLOToolHandler := tools.NewGetSLOTool(cfg.ServiceApp)
60+
registerTool(server, getSLOTool, getSLOToolHandler)
61+
registeredTools++
5862

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

internal/http/mcp/tools/get_slo.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package tools
2+
3+
import (
4+
"context"
5+
6+
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
7+
8+
backendapp "github.com/slok/sloth/internal/http/backend/app"
9+
)
10+
11+
type SLOGetter interface {
12+
GetSLO(ctx context.Context, req backendapp.GetSLORequest) (*backendapp.GetSLOResponse, error)
13+
}
14+
15+
func NewGetSLOTool(app SLOGetter) (*sdkmcp.Tool, sdkmcp.ToolHandlerFor[getSLOToolInput, getSLOToolOutput]) {
16+
return &sdkmcp.Tool{
17+
Name: "get_slo",
18+
Description: "Get a single SLO with its current budget and alert status.",
19+
Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true},
20+
}, newGetSLOToolHandler(app)
21+
}
22+
23+
type getSLOToolInput struct {
24+
SLOID string `json:"slo_id" jsonschema:"required,The SLO ID to retrieve"`
25+
}
26+
27+
type getSLOToolOutput struct {
28+
SLO listSLOsToolOutputItem `json:"slo" jsonschema:"the requested SLO with its current status"`
29+
}
30+
31+
func newGetSLOToolHandler(app SLOGetter) sdkmcp.ToolHandlerFor[getSLOToolInput, getSLOToolOutput] {
32+
return func(ctx context.Context, _ *sdkmcp.CallToolRequest, input getSLOToolInput) (*sdkmcp.CallToolResult, getSLOToolOutput, error) {
33+
resp, err := app.GetSLO(ctx, backendapp.GetSLORequest{SLOID: input.SLOID})
34+
if err != nil {
35+
return nil, getSLOToolOutput{}, err
36+
}
37+
38+
return nil, getSLOToolOutput{SLO: mapRealTimeSLOToToolOutputItem(resp.SLO)}, nil
39+
}
40+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package tools
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/toolsmock"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/mock"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func TestNewGetSLOTool(t *testing.T) {
18+
tests := map[string]struct {
19+
input getSLOToolInput
20+
mock func(m *toolsmock.SLOGetter)
21+
expErr bool
22+
expResp getSLOToolOutput
23+
}{
24+
"It should map the backend request and response.": {
25+
input: getSLOToolInput{SLOID: "slo-id"},
26+
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)
50+
},
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+
}},
67+
},
68+
"Having a backend error should fail.": {
69+
input: getSLOToolInput{SLOID: "slo-id"},
70+
mock: func(m *toolsmock.SLOGetter) {
71+
m.On("GetSLO", mock.Anything, backendapp.GetSLORequest{SLOID: "slo-id"}).Once().Return(nil, fmt.Errorf("something wrong"))
72+
},
73+
expErr: true,
74+
},
75+
}
76+
77+
for name, test := range tests {
78+
t.Run(name, func(t *testing.T) {
79+
m := toolsmock.NewSLOGetter(t)
80+
if test.mock != nil {
81+
test.mock(m)
82+
}
83+
84+
tool, handler := NewGetSLOTool(m)
85+
require.NotNil(t, tool)
86+
assert.Equal(t, "get_slo", tool.Name)
87+
require.NotNil(t, tool.Annotations)
88+
assert.True(t, tool.Annotations.ReadOnlyHint)
89+
90+
result, gotResp, err := handler(context.Background(), nil, test.input)
91+
assert.Nil(t, result)
92+
93+
if test.expErr {
94+
assert.Error(t, err)
95+
} else {
96+
require.NoError(t, err)
97+
}
98+
99+
assert.Equal(t, test.expResp, gotResp)
100+
})
101+
}
102+
}

internal/http/mcp/tools/list_slos.go

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -94,31 +94,35 @@ func newListSLOsToolHandler(app SLOLister) sdkmcp.ToolHandlerFor[listSLOsToolInp
9494
}
9595

9696
for _, slo := range resp.SLOs {
97-
item := listSLOsToolOutputItem{
98-
ID: slo.SLO.ID,
99-
SlothID: slo.SLO.SlothID,
100-
Name: slo.SLO.Name,
101-
ServiceID: slo.SLO.ServiceID,
102-
Objective: slo.SLO.Objective,
103-
Period: slo.SLO.PeriodDuration.String(),
104-
IsGrouped: slo.SLO.IsGrouped,
105-
GroupLabels: slo.SLO.GroupLabels,
106-
BurningBudgetPercent: slo.Budget.BurningBudgetPercent,
107-
BurnedBudgetWindowPercent: slo.Budget.BurnedBudgetWindowPercent,
108-
HasPageAlert: slo.Alerts.FiringPage != nil,
109-
HasWarningAlert: slo.Alerts.FiringWarning != nil,
110-
}
111-
112-
if slo.Alerts.FiringPage != nil {
113-
item.PageAlertName = slo.Alerts.FiringPage.Name
114-
}
115-
if slo.Alerts.FiringWarning != nil {
116-
item.WarningAlertName = slo.Alerts.FiringWarning.Name
117-
}
118-
119-
output.SLOs = append(output.SLOs, item)
97+
output.SLOs = append(output.SLOs, mapRealTimeSLOToToolOutputItem(slo))
12098
}
12199

122100
return nil, output, nil
123101
}
124102
}
103+
104+
func mapRealTimeSLOToToolOutputItem(slo backendapp.RealTimeSLODetails) listSLOsToolOutputItem {
105+
item := listSLOsToolOutputItem{
106+
ID: slo.SLO.ID,
107+
SlothID: slo.SLO.SlothID,
108+
Name: slo.SLO.Name,
109+
ServiceID: slo.SLO.ServiceID,
110+
Objective: slo.SLO.Objective,
111+
Period: slo.SLO.PeriodDuration.String(),
112+
IsGrouped: slo.SLO.IsGrouped,
113+
GroupLabels: slo.SLO.GroupLabels,
114+
BurningBudgetPercent: slo.Budget.BurningBudgetPercent,
115+
BurnedBudgetWindowPercent: slo.Budget.BurnedBudgetWindowPercent,
116+
HasPageAlert: slo.Alerts.FiringPage != nil,
117+
HasWarningAlert: slo.Alerts.FiringWarning != nil,
118+
}
119+
120+
if slo.Alerts.FiringPage != nil {
121+
item.PageAlertName = slo.Alerts.FiringPage.Name
122+
}
123+
if slo.Alerts.FiringWarning != nil {
124+
item.WarningAlertName = slo.Alerts.FiringWarning.Name
125+
}
126+
127+
return item
128+
}

internal/http/mcp/tools/toolsmock/mocks.go

Lines changed: 95 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)