mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
0703dd1602
Each spinner created a new tea.NewProgram which sent DECRQM queries for synchronized output mode 2026. When the program exited and restored cooked terminal mode, the terminal's DECRPM response leaked as visible ^[[?2026;2$y characters. Replace Bubble Tea spinner with a simple goroutine animation loop writing directly to stderr via lipgloss.
380 lines
10 KiB
Go
380 lines
10 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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": {
|
|
Command: []string{"non-existent-command", "arg1", "arg2"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// 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": {
|
|
Command: []string{"non-existent-command-1", "arg1"},
|
|
},
|
|
"bad-server-2": {
|
|
Command: []string{"non-existent-command-2", "arg2"},
|
|
},
|
|
},
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|