Files
kit/internal/tools/mcp_test.go
T

380 lines
10 KiB
Go
Raw Normal View History

2025-06-24 09:12:09 +03:00
package tools
import (
"context"
"encoding/json"
2025-06-24 09:12:09 +03:00
"testing"
"time"
"github.com/mark3labs/mcphost/internal/config"
)
func TestMCPToolManager_LoadTools_WithTimeout(t *testing.T) {
manager := NewMCPToolManager()
// Create a config with a non-existent command that should fail
cfg := &config.Config{
MCPServers: map[string]config.MCPServerConfig{
"test-server": {
2025-06-24 15:56:29 +03:00
Command: []string{"non-existent-command", "arg1", "arg2"},
2025-06-24 09:12:09 +03:00
},
},
}
// Create a context with a reasonable timeout
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// This should not hang indefinitely and should return an error
start := time.Now()
err := manager.LoadTools(ctx, cfg)
duration := time.Since(start)
// The operation should complete within our timeout
if duration > 14*time.Second {
t.Errorf("LoadTools took too long: %v, expected to complete within 14 seconds", duration)
}
// We expect an error since the command doesn't exist, but it shouldn't be a timeout
if err == nil {
t.Error("Expected an error for non-existent command, but got nil")
}
t.Logf("LoadTools completed in %v with error: %v", duration, err)
}
func TestMCPToolManager_LoadTools_GracefulFailure(t *testing.T) {
manager := NewMCPToolManager()
// Create a config with multiple servers, some good and some bad
cfg := &config.Config{
MCPServers: map[string]config.MCPServerConfig{
"bad-server-1": {
2025-06-24 15:56:29 +03:00
Command: []string{"non-existent-command-1", "arg1"},
2025-06-24 09:12:09 +03:00
},
"bad-server-2": {
2025-06-24 15:56:29 +03:00
Command: []string{"non-existent-command-2", "arg2"},
2025-06-24 09:12:09 +03:00
},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// This should fail gracefully and return an error since all servers failed
err := manager.LoadTools(ctx, cfg)
// We expect an error since all servers failed
if err == nil {
t.Error("Expected an error when all servers fail, but got nil")
}
// The error should mention that all servers failed
if err != nil && !contains(err.Error(), "all MCP servers failed") {
t.Errorf("Expected error message to mention all servers failed, got: %v", err)
}
t.Logf("LoadTools failed gracefully with error: %v", err)
}
// TestMCPToolManager_ToolWithoutProperties tests handling of tools with no input properties
func TestMCPToolManager_ToolWithoutProperties(t *testing.T) {
manager := NewMCPToolManager()
// Create a config with a builtin todo server (which has tools with properties)
cfg := &config.Config{
MCPServers: map[string]config.MCPServerConfig{
"todo-server": {
Type: "builtin",
Name: "todo",
},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Load the tools - this should work fine
err := manager.LoadTools(ctx, cfg)
if err != nil {
t.Fatalf("Failed to load tools: %v", err)
}
// Get the loaded tools
tools := manager.GetTools()
if len(tools) == 0 {
t.Fatal("No tools were loaded")
}
// Test that we can get tool info for each tool
for _, tool := range tools {
info := tool.Info()
// Check that the tool has a valid name
if info.Name == "" {
t.Error("Tool has empty name")
}
t.Logf("Tool: %s, Description: %s", info.Name, info.Description)
}
}
// TestIssue89_ObjectSchemaMissingProperties tests the fix for issue #89
// This verifies that object schemas with nil properties get an empty properties map
func TestIssue89_ObjectSchemaMissingProperties(t *testing.T) {
// Create a schema that would cause issues with tools that have no input properties
brokenSchema := map[string]any{
"type": "object",
// Properties is nil - this used to cause "object schema missing properties" error
}
// Verify the problematic state
if brokenSchema["type"] == "object" && brokenSchema["properties"] == nil {
t.Log("Found object schema with nil properties - this previously caused validation errors")
}
// Apply the fix - add empty properties
if brokenSchema["type"] == "object" && brokenSchema["properties"] == nil {
brokenSchema["properties"] = map[string]any{}
}
// Verify the fix worked
if brokenSchema["properties"] == nil {
t.Error("Fix failed: object schema still has nil properties")
}
// Verify it marshals cleanly
data, err := json.Marshal(brokenSchema)
if err != nil {
t.Errorf("Failed to marshal fixed schema: %v", err)
}
var result map[string]any
if err := json.Unmarshal(data, &result); err != nil {
t.Errorf("Failed to unmarshal fixed schema: %v", err)
}
if result["type"] != "object" {
t.Error("Schema type should be 'object'")
}
}
// TestConvertExclusiveBoundsToBoolean tests the JSON Schema draft-07 to draft-04 conversion
func TestConvertExclusiveBoundsToBoolean(t *testing.T) {
tests := []struct {
name string
input string
expected map[string]any
}{
{
name: "exclusiveMinimum as number",
input: `{"type": "number", "exclusiveMinimum": 0}`,
expected: map[string]any{
"type": "number",
"minimum": float64(0),
"exclusiveMinimum": true,
},
},
{
name: "exclusiveMaximum as number",
input: `{"type": "number", "exclusiveMaximum": 100}`,
expected: map[string]any{
"type": "number",
"maximum": float64(100),
"exclusiveMaximum": true,
},
},
{
name: "both exclusive bounds as numbers",
input: `{"type": "integer", "exclusiveMinimum": 1, "exclusiveMaximum": 10}`,
expected: map[string]any{
"type": "integer",
"minimum": float64(1),
"exclusiveMinimum": true,
"maximum": float64(10),
"exclusiveMaximum": true,
},
},
{
name: "already boolean exclusiveMinimum (draft-04 style)",
input: `{"type": "number", "minimum": 0, "exclusiveMinimum": true}`,
expected: map[string]any{
"type": "number",
"minimum": float64(0),
"exclusiveMinimum": true,
},
},
{
name: "no exclusive bounds",
input: `{"type": "string", "minLength": 1}`,
expected: map[string]any{
"type": "string",
"minLength": float64(1),
},
},
{
name: "nested properties with exclusive bounds",
input: `{"type": "object", "properties": {"age": {"type": "integer", "exclusiveMinimum": 0}}}`,
expected: map[string]any{
"type": "object",
"properties": map[string]any{
"age": map[string]any{
"type": "integer",
"minimum": float64(0),
"exclusiveMinimum": true,
},
},
},
},
{
name: "array items with exclusive bounds",
input: `{"type": "array", "items": {"type": "number", "exclusiveMaximum": 100}}`,
expected: map[string]any{
"type": "array",
"items": map[string]any{
"type": "number",
"maximum": float64(100),
"exclusiveMaximum": true,
},
},
},
{
name: "allOf with exclusive bounds",
input: `{"allOf": [{"type": "number", "exclusiveMinimum": 0}]}`,
expected: map[string]any{
"allOf": []any{
map[string]any{
"type": "number",
"minimum": float64(0),
"exclusiveMinimum": true,
},
},
},
},
{
name: "additionalProperties with exclusive bounds",
input: `{"type": "object", "additionalProperties": {"type": "integer", "exclusiveMinimum": 0, "exclusiveMaximum": 255}}`,
expected: map[string]any{
"type": "object",
"additionalProperties": map[string]any{
"type": "integer",
"minimum": float64(0),
"exclusiveMinimum": true,
"maximum": float64(255),
"exclusiveMaximum": true,
},
},
},
{
name: "Chrome DevTools MCP style schema (real-world example)",
input: `{"type": "object", "properties": {"timeout": {"type": "integer", "exclusiveMinimum": 0}, "quality": {"type": "number", "minimum": 0, "maximum": 100}}}`,
expected: map[string]any{
"type": "object",
"properties": map[string]any{
"timeout": map[string]any{
"type": "integer",
"minimum": float64(0),
"exclusiveMinimum": true,
},
"quality": map[string]any{
"type": "number",
"minimum": float64(0),
"maximum": float64(100),
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := convertExclusiveBoundsToBoolean([]byte(tt.input))
var got map[string]any
if err := json.Unmarshal(result, &got); err != nil {
t.Fatalf("Failed to unmarshal result: %v", err)
}
if !deepEqual(got, tt.expected) {
t.Errorf("convertExclusiveBoundsToBoolean() =\n%v\nwant:\n%v", got, tt.expected)
}
})
}
}
// TestConvertExclusiveBoundsToBoolean_InvalidJSON tests that invalid JSON is returned unchanged
func TestConvertExclusiveBoundsToBoolean_InvalidJSON(t *testing.T) {
invalidJSON := []byte(`{invalid json}`)
result := convertExclusiveBoundsToBoolean(invalidJSON)
if string(result) != string(invalidJSON) {
t.Errorf("Expected invalid JSON to be returned unchanged, got: %s", string(result))
}
}
// deepEqual compares two maps recursively
func deepEqual(a, b map[string]any) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
bv, ok := b[k]
if !ok {
return false
}
switch av := v.(type) {
case map[string]any:
bvm, ok := bv.(map[string]any)
if !ok || !deepEqual(av, bvm) {
return false
}
case []any:
bva, ok := bv.([]any)
if !ok || !sliceEqual(av, bva) {
return false
}
default:
if v != bv {
return false
}
}
}
return true
}
// sliceEqual compares two slices recursively
func sliceEqual(a, b []any) bool {
if len(a) != len(b) {
return false
}
for i := range a {
switch av := a[i].(type) {
case map[string]any:
bvm, ok := b[i].(map[string]any)
if !ok || !deepEqual(av, bvm) {
return false
}
case []any:
bva, ok := b[i].([]any)
if !ok || !sliceEqual(av, bva) {
return false
}
default:
if a[i] != b[i] {
return false
}
}
}
return true
}
2025-06-24 09:12:09 +03:00
// Helper function to check if a string contains a substring
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
2025-06-27 11:33:44 +03:00
}