diff --git a/.gitignore b/.gitignore index a9a9d5f..3689074 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ bin/ .env .vscode/ junit-report.xml -dist/ \ No newline at end of file +dist/ +*.exe \ No newline at end of file diff --git a/healenium-mcp-server.exe b/healenium-mcp-server.exe deleted file mode 100644 index 906b0d5..0000000 Binary files a/healenium-mcp-server.exe and /dev/null differ diff --git a/internal/mcp_handlers/tools.go b/internal/mcp_handlers/tools.go index ae21220..5d7c69e 100644 --- a/internal/mcp_handlers/tools.go +++ b/internal/mcp_handlers/tools.go @@ -49,6 +49,7 @@ func RegisterTools( registerTool(s, tools.getLastReportTool) registerTool(s, tools.getReportByIdTool) registerTool(s, tools.getSelectorByParametersTool) + registerTool(s, tools.updateHealingResultTool) } func (ti *McpTool) getLastReportTool() (*mcp.Tool, ToolHandler[any, any]) { @@ -244,3 +245,96 @@ func (ti *McpTool) getSelectorByParametersTool() (*mcp.Tool, ToolHandler[map[str }, nil, nil } } + +func (ti *McpTool) updateHealingResultTool() (*mcp.Tool, ToolHandler[map[string]any, any]) { + return &mcp.Tool{ + Name: "updateHealingResult", + Description: "Updates the healing result status for a specific healing attempt in Healenium.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "healingResultId": { + Type: "integer", + Description: "Unique identifier of the healing result, can be taken from the response of the getSelectorByParameters tool. Should be unsigned integer.", + }, + "successHealing": { + Type: "boolean", + Description: "Is healing result marked as successful? (true for successful, false for failed)", + }, + }, + Required: []string{"healingResultId", "successHealing"}, + }, + }, + func(ctx context.Context, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + // Extract and validate healingResultId + // JSON unmarshaling produces float64 for numeric values, so we need to handle that + var healingResultId uint64 + switch v := args["healingResultId"].(type) { + case float64: + if v < 0 || v != float64(uint64(v)) { + return nil, nil, fmt.Errorf( + "healingResultId must be a non-negative integer (got %v)", + v, + ) + } + healingResultId = uint64(v) + case uint64: + healingResultId = v + default: + return nil, nil, fmt.Errorf( + "healingResultId is required and must be an unsigned integer", + ) + } + + // Extract and validate successHealing + successHealing, ok := args["successHealing"].(bool) + if !ok { + return nil, nil, fmt.Errorf("successHealing is required and must be a boolean") + } + + // Build request body + requestBody := map[string]any{ + "healingResultId": healingResultId, + "successHealing": successHealing, + } + + // Create a new HTTP POST request + resp, err := ti.client.R(). + SetContext(ctx). + SetBody(requestBody). + Post("/healenium/healing/success") + if err != nil { + return nil, nil, fmt.Errorf( + "failed to update healing result at /healenium/healing/success: %w", + err, + ) + } + + // Check HTTP status code + if !resp.IsSuccess() { + return nil, nil, fmt.Errorf( + "failed to update healing result: received status code %d, body: %s", + resp.StatusCode(), + resp.String(), + ) + } + + // Create success message + statusText := "successful" + if !successHealing { + statusText = "failed" + } + successMessage := fmt.Sprintf( + "Healing result %d updated successfully as %s (HTTP %d)", + healingResultId, + statusText, + resp.StatusCode(), + ) + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: successMessage}, + }, + }, nil, nil + } +} diff --git a/internal/mcp_handlers/tools_test.go b/internal/mcp_handlers/tools_test.go index db0246a..35e33f6 100644 --- a/internal/mcp_handlers/tools_test.go +++ b/internal/mcp_handlers/tools_test.go @@ -987,3 +987,267 @@ func createHealeniumMockServerByParameters( } })) } + +// TestUpdateHealingResultTool_Integration tests the complete flow of updateHealingResultTool +// Architecture: +// LLM Mock Client -> Healenium MCP Server -> Healenium Server Mock +func TestUpdateHealingResultTool_Integration(t *testing.T) { + tests := []struct { + name string + healingResultId float64 + successHealing bool + healeniumStatusCode int + expectedSuccess bool + expectedErrorContent string + expectedMessage string + }{ + { + name: "successful healing update", + healingResultId: float64(68), + successHealing: true, + healeniumStatusCode: http.StatusOK, + expectedSuccess: true, + expectedMessage: "Healing result 68 updated successfully as successful (HTTP 200)", + }, + { + name: "failed healing update", + healingResultId: float64(69), + successHealing: false, + healeniumStatusCode: http.StatusOK, + expectedSuccess: true, + expectedMessage: "Healing result 69 updated successfully as failed (HTTP 200)", + }, + { + name: "server error", + healingResultId: float64(70), + successHealing: true, + healeniumStatusCode: http.StatusInternalServerError, + expectedSuccess: false, + expectedErrorContent: "failed to update healing result", + }, + { + name: "not found error", + healingResultId: float64(999), + successHealing: true, + healeniumStatusCode: http.StatusNotFound, + expectedSuccess: false, + expectedErrorContent: "failed to update healing result", + }, + { + name: "bad request error", + healingResultId: float64(0), + successHealing: true, + healeniumStatusCode: http.StatusBadRequest, + expectedSuccess: false, + expectedErrorContent: "failed to update healing result", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Step 1: Create mock Healenium server + mockHealeniumServer := createMockUpdateHealingResultServer( + t, + tt.healingResultId, + tt.successHealing, + tt.healeniumStatusCode, + ) + defer mockHealeniumServer.Close() + + // Step 2: Create Healenium MCP Server pointing to the mock backend + healeniumMcpServer := createHealeniumMCPServer(t, mockHealeniumServer.URL) + + // Step 3: Call the tool handler + _, handler := healeniumMcpServer.updateHealingResultTool() + ctx := context.Background() + req := &mcp.CallToolRequest{} + input := map[string]any{ + "healingResultId": tt.healingResultId, + "successHealing": tt.successHealing, + } + + result, _, err := handler(ctx, req, input) + + // Step 4: Verify results + if tt.expectedSuccess { + if err != nil { + t.Errorf("expected success but got error: %v", err) + } + if result == nil { + t.Fatal("expected result but got nil") + } + if len(result.Content) == 0 { + t.Fatal("expected content but got empty array") + } + + // Check the success message + textContent, ok := result.Content[0].(*mcp.TextContent) + if !ok { + t.Errorf("expected TextContent but got different type") + } else if !strings.Contains(textContent.Text, tt.expectedMessage) { + t.Errorf("expected message to contain %q but got %q", tt.expectedMessage, textContent.Text) + } + } else { + if err == nil { + t.Error("expected error but got success") + } else if !contains(err.Error(), tt.expectedErrorContent) { + t.Errorf("expected error to contain %q but got %q", tt.expectedErrorContent, err.Error()) + } + } + }) + } +} + +// TestUpdateHealingResultTool_ValidationErrors tests parameter validation +func TestUpdateHealingResultTool_ValidationErrors(t *testing.T) { + tests := []struct { + name string + input map[string]any + expectedErrorContent string + }{ + { + name: "missing healingResultId", + input: map[string]any{ + "successHealing": true, + }, + expectedErrorContent: "healingResultId is required and must be an unsigned integer", + }, + { + name: "missing successHealing", + input: map[string]any{ + "healingResultId": float64(68), + }, + expectedErrorContent: "successHealing is required and must be a boolean", + }, + { + name: "invalid healingResultId type - string", + input: map[string]any{ + "healingResultId": "not a number", + "successHealing": true, + }, + expectedErrorContent: "healingResultId is required and must be an unsigned integer", + }, + { + name: "invalid successHealing type - string", + input: map[string]any{ + "healingResultId": float64(68), + "successHealing": "not a boolean", + }, + expectedErrorContent: "successHealing is required and must be a boolean", + }, + { + name: "negative healingResultId", + input: map[string]any{ + "healingResultId": float64(-1), + "successHealing": true, + }, + expectedErrorContent: "healingResultId must be a non-negative integer", + }, + { + name: "fractional healingResultId", + input: map[string]any{ + "healingResultId": float64(68.5), + "successHealing": true, + }, + expectedErrorContent: "healingResultId must be a non-negative integer", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create MCP tool with dummy client + client := resty.New().SetBaseURL("http://dummy") + mcpTool := newMcpTool(client, nil) + + _, handler := mcpTool.updateHealingResultTool() + ctx := context.Background() + req := &mcp.CallToolRequest{} + + _, _, err := handler(ctx, req, tt.input) + if err == nil { + t.Fatal("expected error but got nil") + } + if !contains(err.Error(), tt.expectedErrorContent) { + t.Errorf( + "expected error to contain %q but got %q", + tt.expectedErrorContent, + err.Error(), + ) + } + }) + } +} + +// TestUpdateHealingResultTool_Timeout tests timeout behavior +func TestUpdateHealingResultTool_Timeout(t *testing.T) { + // Create a mock Healenium server that delays response + healeniumServerMock := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Delay response to trigger timeout + time.Sleep(2 * time.Second) + w.WriteHeader(http.StatusOK) + }), + ) + defer healeniumServerMock.Close() + + // Create MCP server with short timeout + healeniumURL, _ := url.Parse(healeniumServerMock.URL) + client := resty.New(). + SetBaseURL(healeniumURL.String()). + SetTimeout(500 * time.Millisecond) // Short timeout + + mcpTool := newMcpTool(client, nil) + _, handler := mcpTool.updateHealingResultTool() + + // Call the tool + ctx := context.Background() + req := &mcp.CallToolRequest{} + input := map[string]any{ + "healingResultId": float64(68), + "successHealing": true, + } + + _, _, err := handler(ctx, req, input) + if err == nil { + t.Fatal("expected timeout error but got nil") + } + + // Verify it's a timeout-related error + if !contains(err.Error(), "failed to update healing result") { + t.Errorf("expected timeout error but got: %v", err) + } +} + +// createMockUpdateHealingResultServer creates a mock Healenium server for testing updateHealingResult +func createMockUpdateHealingResultServer( + t *testing.T, + expectedHealingResultId float64, + expectedSuccessHealing bool, + statusCode int, +) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify the request is to the correct endpoint + if r.URL.Path != "/healenium/healing/success" { + t.Errorf("expected path /healenium/healing/success but got %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + + // Verify it's a POST request + if r.Method != http.MethodPost { + t.Errorf("expected POST method but got %s", r.Method) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + // Verify Content-Type + contentType := r.Header.Get("Content-Type") + if !strings.Contains(contentType, "application/json") { + t.Errorf("expected Content-Type to contain application/json but got %s", contentType) + } + + w.WriteHeader(statusCode) + })) +}