Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ bin/
.env
.vscode/
junit-report.xml
dist/
dist/
*.exe
Binary file removed healenium-mcp-server.exe
Binary file not shown.
94 changes: 94 additions & 0 deletions internal/mcp_handlers/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down Expand Up @@ -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.",
Comment thread
AleksandrParamonoff marked this conversation as resolved.
},
"successHealing": {
Type: "boolean",
Description: "Is healing result marked as successful? (true for successful, false for failed)",
Comment thread
AleksandrParamonoff marked this conversation as resolved.
},
},
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
}
}
264 changes: 264 additions & 0 deletions internal/mcp_handlers/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Comment thread
AleksandrParamonoff marked this conversation as resolved.

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)
}))
Comment thread
AleksandrParamonoff marked this conversation as resolved.
}
Loading