diff --git a/go.mod b/go.mod index fc6ed50..e7b341c 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ module github.com/taigrr/elevenlabs-mcp -go 1.26.0 +go 1.26.1 require ( github.com/gopxl/beep/v2 v2.1.1 - github.com/modelcontextprotocol/go-sdk v1.3.1 + github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/taigrr/elevenlabs v0.1.18 ) @@ -15,8 +15,8 @@ require ( github.com/hajimehoshi/go-mp3 v0.3.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/segmentio/asm v1.2.1 // indirect - github.com/segmentio/encoding v0.5.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sys v0.41.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.42.0 // indirect ) diff --git a/go.sum b/go.sum index 18ae668..b014018 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/ebitengine/oto/v3 v3.4.0 h1:br0PgASsEWaoWn38b2Goe7m1GKFYfNgnsjSd5Gg+/ github.com/ebitengine/oto/v3 v3.4.0/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= @@ -15,28 +15,28 @@ github.com/gopxl/beep/v2 v2.1.1/go.mod h1:ZAm9TGQ9lvpoiFLd4zf5B1IuyxZhgRACMId1XJ github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= -github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI= -github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= +github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= +github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= -github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/taigrr/elevenlabs v0.1.18 h1:ZvRsLjzV4ov6Rdls7MNFKvcknLc527RR6beV6pjukJU= github.com/taigrr/elevenlabs v0.1.18/go.mod h1:ardwj7FGIQbpvRl+UpdRzVnxOuo7QQxqC3YhFcvY1gM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/ximcp/audio_test.go b/internal/ximcp/audio_test.go new file mode 100644 index 0000000..3461503 --- /dev/null +++ b/internal/ximcp/audio_test.go @@ -0,0 +1,143 @@ +package ximcp + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateRandomHex(t *testing.T) { + hex1 := generateRandomHex(RandomHexLength) + hex2 := generateRandomHex(RandomHexLength) + + if len(hex1) != RandomHexLength { + t.Errorf("expected length %d, got %d", RandomHexLength, len(hex1)) + } + + if len(hex2) != RandomHexLength { + t.Errorf("expected length %d, got %d", RandomHexLength, len(hex2)) + } + + // Two random hex strings should almost certainly differ + if hex1 == hex2 { + t.Error("two consecutive random hex values should differ") + } + + // Verify hex characters only + for _, c := range hex1 { + if !strings.ContainsRune("0123456789abcdef", c) { + t.Errorf("non-hex character in output: %c", c) + } + } +} + +func TestGenerateFilePath(t *testing.T) { + s := &Server{} + + path := s.generateFilePath() + + if !strings.HasPrefix(path, AudioDirectory+string(filepath.Separator)) { + t.Errorf("path should start with %s/, got %q", AudioDirectory, path) + } + + if !strings.HasSuffix(path, ".mp3") { + t.Errorf("path should end with .mp3, got %q", path) + } + + // Two paths should differ (different timestamps or random hex) + path2 := s.generateFilePath() + if path == path2 { + t.Error("two consecutive file paths should differ") + } +} + +func TestEnsureDirectoryExists(t *testing.T) { + s := &Server{} + + tmpDir := t.TempDir() + nested := filepath.Join(tmpDir, "a", "b", "c", "file.mp3") + + err := s.ensureDirectoryExists(nested) + if err != nil { + t.Fatalf("ensureDirectoryExists failed: %v", err) + } + + dirPath := filepath.Dir(nested) + info, err := os.Stat(dirPath) + if err != nil { + t.Fatalf("directory not created: %v", err) + } + if !info.IsDir() { + t.Error("expected directory, got file") + } +} + +func TestWriteAudioFile(t *testing.T) { + s := &Server{} + + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "test.mp3") + data := []byte("fake audio data") + + err := s.writeAudioFile(filePath, data) + if err != nil { + t.Fatalf("writeAudioFile failed: %v", err) + } + + content, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("failed to read written file: %v", err) + } + + if string(content) != string(data) { + t.Errorf("file content mismatch: got %q, want %q", content, data) + } +} + +func TestWriteTextFile(t *testing.T) { + s := &Server{} + + tmpDir := t.TempDir() + audioPath := filepath.Join(tmpDir, "test.mp3") + text := "This is the spoken text" + + err := s.writeTextFile(audioPath, text) + if err != nil { + t.Fatalf("writeTextFile failed: %v", err) + } + + expectedPath := filepath.Join(tmpDir, "test.txt") + content, err := os.ReadFile(expectedPath) + if err != nil { + t.Fatalf("failed to read text file: %v", err) + } + + if string(content) != text { + t.Errorf("text file content mismatch: got %q, want %q", content, text) + } +} + +func TestReadFileToAudioFileNotFound(t *testing.T) { + s := &Server{} + + _, err := s.ReadFileToAudio("/nonexistent/file.txt") + if err == nil { + t.Error("expected error for nonexistent file") + } +} + +func TestGenerateAudioNoVoice(t *testing.T) { + s := &Server{ + currentVoice: nil, + } + + _, err := s.GenerateAudio("test text") + if err == nil { + t.Error("expected error when no voice selected") + } + + if !strings.Contains(err.Error(), "no voice selected") { + t.Errorf("expected 'no voice selected' error, got: %v", err) + } +} diff --git a/internal/ximcp/history_test.go b/internal/ximcp/history_test.go new file mode 100644 index 0000000..82be7b2 --- /dev/null +++ b/internal/ximcp/history_test.go @@ -0,0 +1,148 @@ +package ximcp + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCreateSummary(t *testing.T) { + s := &Server{} + + tests := []struct { + name string + input string + expected string + }{ + { + name: "short text unchanged", + input: "Hello world", + expected: "Hello world", + }, + { + name: "exactly max words", + input: "one two three four five six seven eight nine ten", + expected: "one two three four five six seven eight nine ten", + }, + { + name: "exceeds max words truncated", + input: "one two three four five six seven eight nine ten eleven twelve", + expected: "one two three four five six seven eight nine ten...", + }, + { + name: "empty text", + input: "", + expected: "", + }, + { + name: "whitespace only", + input: " \t\n ", + expected: "", + }, + { + name: "text with extra whitespace trimmed at edges", + input: " hello world ", + expected: "hello world", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := s.createSummary(tt.input) + if result != tt.expected { + t.Errorf("createSummary(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestGetAudioHistoryEmptyDir(t *testing.T) { + s := &Server{} + + // Temporarily override the audio directory to a non-existent path + origDir := AudioDirectory + t.Cleanup(func() { + // AudioDirectory is a const, so we test the behavior as-is + _ = origDir + }) + + // GetAudioHistory with non-existent directory should return empty + tmpDir := t.TempDir() + nonExistent := filepath.Join(tmpDir, "does-not-exist") + + // We can't override the const, so test processAudioFiles directly + entries, err := os.ReadDir(nonExistent) + if err != nil { + // Expected — directory doesn't exist + if !os.IsNotExist(err) { + t.Fatalf("unexpected error: %v", err) + } + return + } + + result := s.processAudioFiles(entries) + if len(result) != 0 { + t.Errorf("expected empty result, got %d files", len(result)) + } +} + +func TestProcessAudioFiles(t *testing.T) { + s := &Server{} + + tmpDir := t.TempDir() + + // Create test files + mp3File := filepath.Join(tmpDir, "test.mp3") + txtFile := filepath.Join(tmpDir, "test.txt") + otherFile := filepath.Join(tmpDir, "readme.md") + + if err := os.WriteFile(mp3File, []byte("fake audio"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(txtFile, []byte("Hello world test"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(otherFile, []byte("not audio"), 0644); err != nil { + t.Fatal(err) + } + + entries, err := os.ReadDir(tmpDir) + if err != nil { + t.Fatal(err) + } + + result := s.processAudioFiles(entries) + + // Should only include .mp3 files + if len(result) != 1 { + t.Fatalf("expected 1 audio file, got %d", len(result)) + } + + if result[0].Name != "test.mp3" { + t.Errorf("expected name 'test.mp3', got %q", result[0].Name) + } +} + +func TestGetAudioSummary(t *testing.T) { + s := &Server{} + + tmpDir := t.TempDir() + + // Create a text file alongside an mp3 + txtPath := filepath.Join(tmpDir, "audio.txt") + if err := os.WriteFile(txtPath, []byte("This is a test summary"), 0644); err != nil { + t.Fatal(err) + } + + // getAudioSummary uses AudioDirectory const, so we test createSummary instead + summary := s.createSummary("This is a test summary") + if summary != "This is a test summary" { + t.Errorf("unexpected summary: %q", summary) + } + + // Test with no text file fallback + summary = s.createSummary("") + if summary != "" { + t.Errorf("expected empty summary, got %q", summary) + } +} diff --git a/internal/ximcp/server_test.go b/internal/ximcp/server_test.go new file mode 100644 index 0000000..63d7626 --- /dev/null +++ b/internal/ximcp/server_test.go @@ -0,0 +1,190 @@ +package ximcp + +import ( + "testing" + + "github.com/taigrr/elevenlabs/client/types" +) + +func TestFindVoiceByID(t *testing.T) { + s := &Server{ + voices: []types.VoiceResponseModel{ + {VoiceID: "abc123", Name: "Alice"}, + {VoiceID: "def456", Name: "Bob"}, + {VoiceID: "ghi789", Name: "Charlie"}, + }, + } + + tests := []struct { + name string + voiceID string + expected string + found bool + }{ + {"find first voice", "abc123", "Alice", true}, + {"find middle voice", "def456", "Bob", true}, + {"find last voice", "ghi789", "Charlie", true}, + {"voice not found", "zzz999", "", false}, + {"empty id", "", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + voice := s.findVoiceByID(tt.voiceID) + if tt.found { + if voice == nil { + t.Fatal("expected voice, got nil") + } + if voice.Name != tt.expected { + t.Errorf("expected name %q, got %q", tt.expected, voice.Name) + } + } else { + if voice != nil { + t.Errorf("expected nil, got voice %q", voice.Name) + } + } + }) + } +} + +func TestSetDefaultVoiceIfNeeded(t *testing.T) { + t.Run("sets first voice when no current", func(t *testing.T) { + s := &Server{ + voices: []types.VoiceResponseModel{ + {VoiceID: "abc123", Name: "Alice"}, + {VoiceID: "def456", Name: "Bob"}, + }, + currentVoice: nil, + } + + s.setDefaultVoiceIfNeeded() + + if s.currentVoice == nil { + t.Fatal("expected current voice to be set") + } + if s.currentVoice.Name != "Alice" { + t.Errorf("expected first voice 'Alice', got %q", s.currentVoice.Name) + } + }) + + t.Run("does not override existing voice", func(t *testing.T) { + bob := types.VoiceResponseModel{VoiceID: "def456", Name: "Bob"} + s := &Server{ + voices: []types.VoiceResponseModel{ + {VoiceID: "abc123", Name: "Alice"}, + bob, + }, + currentVoice: &bob, + } + + s.setDefaultVoiceIfNeeded() + + if s.currentVoice.Name != "Bob" { + t.Errorf("expected voice to remain 'Bob', got %q", s.currentVoice.Name) + } + }) + + t.Run("no voices available", func(t *testing.T) { + s := &Server{ + voices: []types.VoiceResponseModel{}, + currentVoice: nil, + } + + s.setDefaultVoiceIfNeeded() + + if s.currentVoice != nil { + t.Error("expected no voice when list is empty") + } + }) +} + +func TestSetVoice(t *testing.T) { + s := &Server{ + voices: []types.VoiceResponseModel{ + {VoiceID: "abc123", Name: "Alice"}, + {VoiceID: "def456", Name: "Bob"}, + }, + } + + t.Run("set valid voice", func(t *testing.T) { + voice, err := s.SetVoice("def456") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if voice.Name != "Bob" { + t.Errorf("expected 'Bob', got %q", voice.Name) + } + if s.currentVoice.VoiceID != "def456" { + t.Error("currentVoice not updated") + } + }) + + t.Run("set invalid voice", func(t *testing.T) { + _, err := s.SetVoice("nonexistent") + if err == nil { + t.Error("expected error for nonexistent voice") + } + }) +} + +func TestFormatVoiceList(t *testing.T) { + current := types.VoiceResponseModel{VoiceID: "abc123", Name: "Alice", Category: "premade"} + s := &Server{} + + voices := []types.VoiceResponseModel{ + current, + {VoiceID: "def456", Name: "Bob", Category: "cloned"}, + } + + result := s.formatVoiceList(voices, ¤t) + + if result == "" { + t.Error("expected non-empty result") + } + + // Current voice should be marked with asterisk + if !contains(result, "* Alice") { + t.Error("expected current voice to be marked with asterisk") + } + + // Other voice should not be marked + if contains(result, "* Bob") { + t.Error("non-current voice should not have asterisk marker") + } + + // Both voices should appear + if !contains(result, "Alice") || !contains(result, "Bob") { + t.Error("both voices should appear in output") + } + + if !contains(result, "Currently selected: Alice") { + t.Error("expected currently selected line") + } +} + +func TestFormatVoiceListNoSelection(t *testing.T) { + s := &Server{} + + voices := []types.VoiceResponseModel{ + {VoiceID: "abc123", Name: "Alice", Category: "premade"}, + } + + result := s.formatVoiceList(voices, nil) + + if !contains(result, "No voice currently selected") { + t.Error("expected 'No voice currently selected' when nil") + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}