Skip to content

Commit cb1c10b

Browse files
EPMHLM-445 || MCP. Tools. Set healing status
1 parent 8e7bbc5 commit cb1c10b

4 files changed

Lines changed: 360 additions & 1 deletion

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ bin/
33
.env
44
.vscode/
55
junit-report.xml
6-
dist/
6+
dist/
7+
*.exe

healenium-mcp-server.exe

-16.3 MB
Binary file not shown.

internal/mcp_handlers/tools.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func RegisterTools(
4949
registerTool(s, tools.getLastReportTool)
5050
registerTool(s, tools.getReportByIdTool)
5151
registerTool(s, tools.getSelectorByParametersTool)
52+
registerTool(s, tools.updateHealingResultTool)
5253
}
5354

5455
func (ti *McpTool) getLastReportTool() (*mcp.Tool, ToolHandler[any, any]) {
@@ -244,3 +245,96 @@ func (ti *McpTool) getSelectorByParametersTool() (*mcp.Tool, ToolHandler[map[str
244245
}, nil, nil
245246
}
246247
}
248+
249+
func (ti *McpTool) updateHealingResultTool() (*mcp.Tool, ToolHandler[map[string]any, any]) {
250+
return &mcp.Tool{
251+
Name: "updateHealingResult",
252+
Description: "Updates the healing result status for a specific healing attempt in Healenium.",
253+
InputSchema: &jsonschema.Schema{
254+
Type: "object",
255+
Properties: map[string]*jsonschema.Schema{
256+
"healingResultId": {
257+
Type: "integer",
258+
Description: "Unique identifier of the healing result, can be taken from the response of the getSelectorByParameters tool. Should be unsigned integer.",
259+
},
260+
"successHealing": {
261+
Type: "boolean",
262+
Description: "Is healing result marked as successful? (true for successful, false for failed)",
263+
},
264+
},
265+
Required: []string{"healingResultId", "successHealing"},
266+
},
267+
},
268+
func(ctx context.Context, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
269+
// Extract and validate healingResultId
270+
// JSON unmarshaling produces float64 for numeric values, so we need to handle that
271+
var healingResultId uint64
272+
switch v := args["healingResultId"].(type) {
273+
case float64:
274+
if v < 0 || v != float64(uint64(v)) {
275+
return nil, nil, fmt.Errorf(
276+
"healingResultId must be a non-negative integer (got %v)",
277+
v,
278+
)
279+
}
280+
healingResultId = uint64(v)
281+
case uint64:
282+
healingResultId = v
283+
default:
284+
return nil, nil, fmt.Errorf(
285+
"healingResultId is required and must be an unsigned integer",
286+
)
287+
}
288+
289+
// Extract and validate successHealing
290+
successHealing, ok := args["successHealing"].(bool)
291+
if !ok {
292+
return nil, nil, fmt.Errorf("successHealing is required and must be a boolean")
293+
}
294+
295+
// Build request body
296+
requestBody := map[string]any{
297+
"healingResultId": healingResultId,
298+
"successHealing": successHealing,
299+
}
300+
301+
// Create a new HTTP POST request
302+
resp, err := ti.client.R().
303+
SetContext(ctx).
304+
SetBody(requestBody).
305+
Post("/healenium/healing/success")
306+
if err != nil {
307+
return nil, nil, fmt.Errorf(
308+
"failed to update healing result at /healenium/healing/success: %w",
309+
err,
310+
)
311+
}
312+
313+
// Check HTTP status code
314+
if !resp.IsSuccess() {
315+
return nil, nil, fmt.Errorf(
316+
"failed to update healing result: received status code %d, body: %s",
317+
resp.StatusCode(),
318+
resp.String(),
319+
)
320+
}
321+
322+
// Create success message
323+
statusText := "successful"
324+
if !successHealing {
325+
statusText = "failed"
326+
}
327+
successMessage := fmt.Sprintf(
328+
"Healing result %d updated successfully as %s (HTTP %d)",
329+
healingResultId,
330+
statusText,
331+
resp.StatusCode(),
332+
)
333+
334+
return &mcp.CallToolResult{
335+
Content: []mcp.Content{
336+
&mcp.TextContent{Text: successMessage},
337+
},
338+
}, nil, nil
339+
}
340+
}

internal/mcp_handlers/tools_test.go

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -987,3 +987,267 @@ func createHealeniumMockServerByParameters(
987987
}
988988
}))
989989
}
990+
991+
// TestUpdateHealingResultTool_Integration tests the complete flow of updateHealingResultTool
992+
// Architecture:
993+
// LLM Mock Client -> Healenium MCP Server -> Healenium Server Mock
994+
func TestUpdateHealingResultTool_Integration(t *testing.T) {
995+
tests := []struct {
996+
name string
997+
healingResultId float64
998+
successHealing bool
999+
healeniumStatusCode int
1000+
expectedSuccess bool
1001+
expectedErrorContent string
1002+
expectedMessage string
1003+
}{
1004+
{
1005+
name: "successful healing update",
1006+
healingResultId: float64(68),
1007+
successHealing: true,
1008+
healeniumStatusCode: http.StatusOK,
1009+
expectedSuccess: true,
1010+
expectedMessage: "Healing result 68 updated successfully as successful (HTTP 200)",
1011+
},
1012+
{
1013+
name: "failed healing update",
1014+
healingResultId: float64(69),
1015+
successHealing: false,
1016+
healeniumStatusCode: http.StatusOK,
1017+
expectedSuccess: true,
1018+
expectedMessage: "Healing result 69 updated successfully as failed (HTTP 200)",
1019+
},
1020+
{
1021+
name: "server error",
1022+
healingResultId: float64(70),
1023+
successHealing: true,
1024+
healeniumStatusCode: http.StatusInternalServerError,
1025+
expectedSuccess: false,
1026+
expectedErrorContent: "failed to update healing result",
1027+
},
1028+
{
1029+
name: "not found error",
1030+
healingResultId: float64(999),
1031+
successHealing: true,
1032+
healeniumStatusCode: http.StatusNotFound,
1033+
expectedSuccess: false,
1034+
expectedErrorContent: "failed to update healing result",
1035+
},
1036+
{
1037+
name: "bad request error",
1038+
healingResultId: float64(0),
1039+
successHealing: true,
1040+
healeniumStatusCode: http.StatusBadRequest,
1041+
expectedSuccess: false,
1042+
expectedErrorContent: "failed to update healing result",
1043+
},
1044+
}
1045+
1046+
for _, tt := range tests {
1047+
t.Run(tt.name, func(t *testing.T) {
1048+
// Step 1: Create mock Healenium server
1049+
mockHealeniumServer := createMockUpdateHealingResultServer(
1050+
t,
1051+
tt.healingResultId,
1052+
tt.successHealing,
1053+
tt.healeniumStatusCode,
1054+
)
1055+
defer mockHealeniumServer.Close()
1056+
1057+
// Step 2: Create Healenium MCP Server pointing to the mock backend
1058+
healeniumMcpServer := createHealeniumMCPServer(t, mockHealeniumServer.URL)
1059+
1060+
// Step 3: Call the tool handler
1061+
_, handler := healeniumMcpServer.updateHealingResultTool()
1062+
ctx := context.Background()
1063+
req := &mcp.CallToolRequest{}
1064+
input := map[string]any{
1065+
"healingResultId": tt.healingResultId,
1066+
"successHealing": tt.successHealing,
1067+
}
1068+
1069+
result, _, err := handler(ctx, req, input)
1070+
1071+
// Step 4: Verify results
1072+
if tt.expectedSuccess {
1073+
if err != nil {
1074+
t.Errorf("expected success but got error: %v", err)
1075+
}
1076+
if result == nil {
1077+
t.Fatal("expected result but got nil")
1078+
}
1079+
if len(result.Content) == 0 {
1080+
t.Fatal("expected content but got empty array")
1081+
}
1082+
1083+
// Check the success message
1084+
textContent, ok := result.Content[0].(*mcp.TextContent)
1085+
if !ok {
1086+
t.Errorf("expected TextContent but got different type")
1087+
} else if !strings.Contains(textContent.Text, tt.expectedMessage) {
1088+
t.Errorf("expected message to contain %q but got %q", tt.expectedMessage, textContent.Text)
1089+
}
1090+
} else {
1091+
if err == nil {
1092+
t.Error("expected error but got success")
1093+
} else if !contains(err.Error(), tt.expectedErrorContent) {
1094+
t.Errorf("expected error to contain %q but got %q", tt.expectedErrorContent, err.Error())
1095+
}
1096+
}
1097+
})
1098+
}
1099+
}
1100+
1101+
// TestUpdateHealingResultTool_ValidationErrors tests parameter validation
1102+
func TestUpdateHealingResultTool_ValidationErrors(t *testing.T) {
1103+
tests := []struct {
1104+
name string
1105+
input map[string]any
1106+
expectedErrorContent string
1107+
}{
1108+
{
1109+
name: "missing healingResultId",
1110+
input: map[string]any{
1111+
"successHealing": true,
1112+
},
1113+
expectedErrorContent: "healingResultId is required and must be an unsigned integer",
1114+
},
1115+
{
1116+
name: "missing successHealing",
1117+
input: map[string]any{
1118+
"healingResultId": float64(68),
1119+
},
1120+
expectedErrorContent: "successHealing is required and must be a boolean",
1121+
},
1122+
{
1123+
name: "invalid healingResultId type - string",
1124+
input: map[string]any{
1125+
"healingResultId": "not a number",
1126+
"successHealing": true,
1127+
},
1128+
expectedErrorContent: "healingResultId is required and must be an unsigned integer",
1129+
},
1130+
{
1131+
name: "invalid successHealing type - string",
1132+
input: map[string]any{
1133+
"healingResultId": float64(68),
1134+
"successHealing": "not a boolean",
1135+
},
1136+
expectedErrorContent: "successHealing is required and must be a boolean",
1137+
},
1138+
{
1139+
name: "negative healingResultId",
1140+
input: map[string]any{
1141+
"healingResultId": float64(-1),
1142+
"successHealing": true,
1143+
},
1144+
expectedErrorContent: "healingResultId must be a non-negative integer",
1145+
},
1146+
{
1147+
name: "fractional healingResultId",
1148+
input: map[string]any{
1149+
"healingResultId": float64(68.5),
1150+
"successHealing": true,
1151+
},
1152+
expectedErrorContent: "healingResultId must be a non-negative integer",
1153+
},
1154+
}
1155+
1156+
for _, tt := range tests {
1157+
t.Run(tt.name, func(t *testing.T) {
1158+
// Create MCP tool with dummy client
1159+
client := resty.New().SetBaseURL("http://dummy")
1160+
mcpTool := newMcpTool(client, nil)
1161+
1162+
_, handler := mcpTool.updateHealingResultTool()
1163+
ctx := context.Background()
1164+
req := &mcp.CallToolRequest{}
1165+
1166+
_, _, err := handler(ctx, req, tt.input)
1167+
if err == nil {
1168+
t.Fatal("expected error but got nil")
1169+
}
1170+
if !contains(err.Error(), tt.expectedErrorContent) {
1171+
t.Errorf(
1172+
"expected error to contain %q but got %q",
1173+
tt.expectedErrorContent,
1174+
err.Error(),
1175+
)
1176+
}
1177+
})
1178+
}
1179+
}
1180+
1181+
// TestUpdateHealingResultTool_Timeout tests timeout behavior
1182+
func TestUpdateHealingResultTool_Timeout(t *testing.T) {
1183+
// Create a mock Healenium server that delays response
1184+
healeniumServerMock := httptest.NewServer(
1185+
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1186+
// Delay response to trigger timeout
1187+
time.Sleep(2 * time.Second)
1188+
w.WriteHeader(http.StatusOK)
1189+
}),
1190+
)
1191+
defer healeniumServerMock.Close()
1192+
1193+
// Create MCP server with short timeout
1194+
healeniumURL, _ := url.Parse(healeniumServerMock.URL)
1195+
client := resty.New().
1196+
SetBaseURL(healeniumURL.String()).
1197+
SetTimeout(500 * time.Millisecond) // Short timeout
1198+
1199+
mcpTool := newMcpTool(client, nil)
1200+
_, handler := mcpTool.updateHealingResultTool()
1201+
1202+
// Call the tool
1203+
ctx := context.Background()
1204+
req := &mcp.CallToolRequest{}
1205+
input := map[string]any{
1206+
"healingResultId": float64(68),
1207+
"successHealing": true,
1208+
}
1209+
1210+
_, _, err := handler(ctx, req, input)
1211+
if err == nil {
1212+
t.Fatal("expected timeout error but got nil")
1213+
}
1214+
1215+
// Verify it's a timeout-related error
1216+
if !contains(err.Error(), "failed to update healing result") {
1217+
t.Errorf("expected timeout error but got: %v", err)
1218+
}
1219+
}
1220+
1221+
// createMockUpdateHealingResultServer creates a mock Healenium server for testing updateHealingResult
1222+
func createMockUpdateHealingResultServer(
1223+
t *testing.T,
1224+
expectedHealingResultId float64,
1225+
expectedSuccessHealing bool,
1226+
statusCode int,
1227+
) *httptest.Server {
1228+
t.Helper()
1229+
1230+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1231+
// Verify the request is to the correct endpoint
1232+
if r.URL.Path != "/healenium/healing/success" {
1233+
t.Errorf("expected path /healenium/healing/success but got %s", r.URL.Path)
1234+
w.WriteHeader(http.StatusNotFound)
1235+
return
1236+
}
1237+
1238+
// Verify it's a POST request
1239+
if r.Method != http.MethodPost {
1240+
t.Errorf("expected POST method but got %s", r.Method)
1241+
w.WriteHeader(http.StatusMethodNotAllowed)
1242+
return
1243+
}
1244+
1245+
// Verify Content-Type
1246+
contentType := r.Header.Get("Content-Type")
1247+
if !strings.Contains(contentType, "application/json") {
1248+
t.Errorf("expected Content-Type to contain application/json but got %s", contentType)
1249+
}
1250+
1251+
w.WriteHeader(statusCode)
1252+
}))
1253+
}

0 commit comments

Comments
 (0)