4 Commits

Author SHA1 Message Date
64cbb43ecc chore(deps): bump Go 1.26.2, go-sdk v1.5.0, fix docs and CI
- Bump Go to 1.26.2 and go-sdk to v1.5.0
- Fix MCP dependency references in README and CRUSH.md (was mark3labs/mcp-go, now modelcontextprotocol/go-sdk)
- Remove stale google/uuid reference from CRUSH.md
- Add staticcheck to CI workflow
- Simplify test helpers (use strings.Contains)
2026-04-13 06:39:15 +00:00
4cfd456802 Merge pull request #2 from taigrr/cd/tests-and-go-bump
test: add comprehensive unit tests and update deps
2026-03-19 13:16:14 -04:00
1c55b42bb3 test: add comprehensive unit tests and update deps
- Add tests for audio file generation, path handling, and error cases
- Add tests for history listing, summary creation, and file filtering
- Add tests for voice management (find, set, default, formatting)
- Update Go to 1.26.1
- Bump go-sdk v1.3.1 → v1.4.1, segmentio/encoding v0.5.4,
  golang.org/x/oauth2 v0.36.0, golang.org/x/sys v0.42.0
2026-03-19 06:02:59 +00:00
23c150f057 Merge pull request #1 from taigrr/cd/modernize
chore: modernize to Go 1.26.0, upgrade deps + add CI
2026-02-23 14:39:23 -05:00
8 changed files with 495 additions and 21 deletions

View File

@@ -15,4 +15,6 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y libasound2-dev
- run: go test -race ./...
- run: go vet ./...
- run: go install honnef.co/go/tools/cmd/staticcheck@latest
- run: staticcheck ./...
- run: go build ./...

View File

@@ -30,7 +30,6 @@
- `history`: List available audio files with text summaries
## Dependencies
- `github.com/mark3labs/mcp-go` - MCP server framework
- `github.com/modelcontextprotocol/go-sdk` - MCP server framework (official SDK)
- `github.com/taigrr/elevenlabs` - ElevenLabs API client
- `github.com/gopxl/beep` - Audio playback
- `github.com/google/uuid` - UUID generation
- `github.com/gopxl/beep` - Audio playback

View File

@@ -54,7 +54,7 @@ The server provides the following tools to MCP clients:
- [ElevenLabs API](https://elevenlabs.io) for text-to-speech generation
- [Beep library](https://github.com/gopxl/beep) for audio playback
- [MCP-Go](https://github.com/mark3labs/mcp-go) for MCP server functionality
- [MCP Go SDK](https://github.com/modelcontextprotocol/go-sdk) for MCP server functionality
## License

10
go.mod
View File

@@ -1,10 +1,10 @@
module github.com/taigrr/elevenlabs-mcp
go 1.26.0
go 1.26.2
require (
github.com/gopxl/beep/v2 v2.1.1
github.com/modelcontextprotocol/go-sdk v1.3.1
github.com/modelcontextprotocol/go-sdk v1.5.0
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.43.0 // indirect
)

24
go.sum
View File

@@ -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.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/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.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,182 @@
package ximcp
import (
"strings"
"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, &current)
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 strings.Contains(s, substr)
}