From 83db12423e669ad13fad71635284d48316b21195 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 7 Aug 2025 22:42:36 -0700 Subject: [PATCH] initial commit --- .gitignore | 40 +++++ CRUSH.md | 36 +++++ README.md | 66 ++++++++ go.mod | 28 ++++ go.sum | 55 +++++++ main.go | 460 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 685 insertions(+) create mode 100644 .gitignore create mode 100644 CRUSH.md create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bfc64b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Crush directory +.crush/ + +# Go build artifacts +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work + +# Dependency directories +vendor/ + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Environment files +.env +.env.local +.env.*.local + +# Audio files (except examples) +*.mp3 +*.wav +*.m4a +!examples/**/*.mp3 +!examples/**/*.wav +!examples/**/*.m4a.xi/ +.xi/ diff --git a/CRUSH.md b/CRUSH.md new file mode 100644 index 0000000..14a6592 --- /dev/null +++ b/CRUSH.md @@ -0,0 +1,36 @@ +# ElevenLabs MCP Server + +## Build/Test Commands +- Build: `go build -o elevenlabs-mcp` +- Run: `./elevenlabs-mcp` (requires XI_API_KEY env var) +- Test: `go test ./...` +- Lint: `golangci-lint run` (if available) or `go vet ./...` +- Format: `gofmt -w .` or `goimports -w .` +- Dependencies: `go mod tidy && go mod download` + +## Environment Setup +- Required: `export XI_API_KEY=your_api_key_here` +- Audio files saved to: `.xi/-.mp3` + +## Code Style +- Use `goimports` for formatting +- Follow Go naming conventions (PascalCase for exported, camelCase for unexported) +- No single-letter variables except loop counters +- Use meaningful error messages with context +- Prefer explicit error handling over panics +- Use sync.RWMutex for concurrent access to shared data +- Constants for magic strings/numbers, defined at package level + +## MCP Tools Provided +- `say`: Convert text to speech, save as MP3 +- `read`: Read text file and convert to speech +- `play`: Play audio file using beep library +- `set_voice`: Change TTS voice (memory only) +- `get_voices`: List available voices, show current selection +- `history`: List available audio files with text summaries + +## Dependencies +- `github.com/mark3labs/mcp-go` - MCP server framework +- `github.com/taigrr/elevenlabs` - ElevenLabs API client +- `github.com/gopxl/beep` - Audio playback +- `github.com/google/uuid` - UUID generation \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a1e343c --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# ElevenLabs MCP Server + +An MCP (Model Context Protocol) server that provides text-to-speech capabilities using ElevenLabs API. + +## Features + +- **say**: Convert text to speech and save as MP3 file +- **read**: Read a text file and convert it to speech +- **play**: Play audio files using the beep library +- **set_voice**: Change the voice used for generation (memory only) +- **get_voices**: List available voices and show currently selected one + +## Setup + +1. Get your ElevenLabs API key from [ElevenLabs](https://elevenlabs.io) +2. Set the environment variable: + ```bash + export XI_API_KEY=your_api_key_here + ``` + +## Build + +```bash +go build -o elevenlabs-mcp +``` + +## Usage + +The server runs via stdio and communicates using the MCP protocol: + +```bash +export XI_API_KEY=your_api_key_here +./elevenlabs-mcp +``` + +## Tools + +### say +Convert text to speech and save as MP3. +- **text** (string, required): Text to convert to speech + +### read +Read a text file and convert it to speech. +- **file_path** (string, required): Path to the text file + +### play +Play an audio file. +- **file_path** (string, required): Path to the audio file + +### set_voice +Change the voice used for generation. +- **voice_id** (string, required): ID of the voice to use + +### get_voices +List all available voices and show the currently selected one. +- No parameters required + +## Audio Files + +Generated audio files are saved to `.crush/xi/.mp3` and can be replayed using the `play` tool. + +## Dependencies + +- ElevenLabs API for text-to-speech generation +- Beep library for audio playback +- Mark3labs MCP-Go for MCP server functionality \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7ab259a --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module github.com/taigrr/elevenlabs-mcp + +go 1.23.2 + +toolchain go1.24.6 + +require ( + github.com/gopxl/beep/v2 v2.1.1 + github.com/mark3labs/mcp-go v0.37.0 + github.com/taigrr/elevenlabs v0.1.18 +) + +require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/ebitengine/oto/v3 v3.3.2 // indirect + github.com/ebitengine/purego v0.8.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hajimehoshi/go-mp3 v0.3.4 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/sys v0.32.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9924965 --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/oto/v3 v3.3.2 h1:VTWBsKX9eb+dXzaF4jEwQbs4yWIdXukJ0K40KgkpYlg= +github.com/ebitengine/oto/v3 v3.3.2/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U= +github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE= +github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopxl/beep/v2 v2.1.1 h1:6FYIYMm2qPAdWkjX+7xwKrViS1x0Po5kDMdRkq8NVbU= +github.com/gopxl/beep/v2 v2.1.1/go.mod h1:ZAm9TGQ9lvpoiFLd4zf5B1IuyxZhgRACMId1XJbaW0E= +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/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-go v0.37.0 h1:BywvZLPRT6Zx6mMG/MJfxLSZQkTGIcJSEGKsvr4DsoQ= +github.com/mark3labs/mcp-go v0.37.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +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/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +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/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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/main.go b/main.go new file mode 100644 index 0000000..62b635d --- /dev/null +++ b/main.go @@ -0,0 +1,460 @@ +package main + +import ( + "context" + "crypto/rand" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/gopxl/beep/v2" + "github.com/gopxl/beep/v2/mp3" + "github.com/gopxl/beep/v2/speaker" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/taigrr/elevenlabs/client" + "github.com/taigrr/elevenlabs/client/types" +) + +type ElevenLabsServer struct { + client client.Client + voices []types.VoiceResponseModel + currentVoice *types.VoiceResponseModel + voicesMutex sync.RWMutex +} + +func NewElevenLabsServer() (*ElevenLabsServer, error) { + apiKey := os.Getenv("XI_API_KEY") + if apiKey == "" { + return nil, fmt.Errorf("XI_API_KEY environment variable is required") + } + + elevenClient := client.New(apiKey) + + s := &ElevenLabsServer{ + client: elevenClient, + } + + // Initialize voices and set default + if err := s.refreshVoices(); err != nil { + return nil, fmt.Errorf("failed to initialize voices: %w", err) + } + + // Initialize speaker for audio playback + sr := beep.SampleRate(44100) + speaker.Init(sr, sr.N(time.Second/10)) + + return s, nil +} + +func (s *ElevenLabsServer) refreshVoices() error { + s.voicesMutex.Lock() + defer s.voicesMutex.Unlock() + + voices, err := s.client.GetVoices(context.Background()) + if err != nil { + return fmt.Errorf("failed to get voices: %w", err) + } + + s.voices = voices + + // Set default voice if none selected + if s.currentVoice == nil && len(voices) > 0 { + s.currentVoice = &voices[0] + } + + return nil +} + +func generateRandomHex(length int) string { + bytes := make([]byte, length) + rand.Read(bytes) + return fmt.Sprintf("%x", bytes)[:length] +} + +func (s *ElevenLabsServer) generateAudio(text string) (string, error) { + if s.currentVoice == nil { + return "", fmt.Errorf("no voice selected") + } + + // Generate audio using TTS + audioData, err := s.client.TTS(context.Background(), text, s.currentVoice.VoiceID, "", types.SynthesisOptions{ + Stability: 0.5, + SimilarityBoost: 0.5, + }) + if err != nil { + return "", fmt.Errorf("failed to generate speech: %w", err) + } + + // Create filename with timestamp and random hex + timestamp := time.Now().UnixMilli() + randomHex := generateRandomHex(5) + filename := fmt.Sprintf("%d-%s.mp3", timestamp, randomHex) + filePath := filepath.Join(".xi", filename) + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return "", fmt.Errorf("failed to create directory: %w", err) + } + + // Write audio file + if err := os.WriteFile(filePath, audioData, 0644); err != nil { + return "", fmt.Errorf("failed to write audio file: %w", err) + } + + // Write text file alongside audio + textFilePath := strings.TrimSuffix(filePath, ".mp3") + ".txt" + if err := os.WriteFile(textFilePath, []byte(text), 0644); err != nil { + return "", fmt.Errorf("failed to write text file: %w", err) + } + + return filePath, nil +} + +func (s *ElevenLabsServer) playAudio(filepath string) error { + file, err := os.Open(filepath) + if err != nil { + return fmt.Errorf("failed to open audio file: %w", err) + } + defer file.Close() + + streamer, format, err := mp3.Decode(file) + if err != nil { + return fmt.Errorf("failed to decode mp3: %w", err) + } + defer streamer.Close() + + resampled := beep.Resample(4, format.SampleRate, 44100, streamer) + + done := make(chan bool) + speaker.Play(beep.Seq(resampled, beep.Callback(func() { + done <- true + }))) + + <-done + return nil +} + +func (s *ElevenLabsServer) setupTools(mcpServer *server.MCPServer) { + // Say tool + sayTool := mcp.Tool{ + Name: "say", + Description: "Convert text to speech and save as MP3 file", + InputSchema: mcp.ToolInputSchema{ + Type: "object", + Properties: map[string]any{ + "text": map[string]any{ + "type": "string", + "description": "Text to convert to speech", + }, + }, + Required: []string{"text"}, + }, + } + + mcpServer.AddTool(sayTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + text, err := request.RequireString("text") + if err != nil { + return nil, err + } + + filepath, err := s.generateAudio(text) + if err != nil { + return nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Audio generated and saved to: %s", filepath), + }, + }, + }, nil + }) + + // Read tool + readTool := mcp.Tool{ + Name: "read", + Description: "Read a text file and convert it to speech, saving as MP3", + InputSchema: mcp.ToolInputSchema{ + Type: "object", + Properties: map[string]any{ + "file_path": map[string]any{ + "type": "string", + "description": "Path to the text file to read and convert to speech", + }, + }, + Required: []string{"file_path"}, + }, + } + + mcpServer.AddTool(readTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + filePath, err := request.RequireString("file_path") + if err != nil { + return nil, err + } + + // Read file content + content, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + text := string(content) + audioPath, err := s.generateAudio(text) + if err != nil { + return nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("File '%s' converted to speech and saved to: %s", filePath, audioPath), + }, + }, + }, nil + }) + + // Play tool + playTool := mcp.Tool{ + Name: "play", + Description: "Play an audio file", + InputSchema: mcp.ToolInputSchema{ + Type: "object", + Properties: map[string]any{ + "file_path": map[string]any{ + "type": "string", + "description": "Path to the audio file to play", + }, + }, + Required: []string{"file_path"}, + }, + } + + mcpServer.AddTool(playTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + filePath, err := request.RequireString("file_path") + if err != nil { + return nil, err + } + + if err := s.playAudio(filePath); err != nil { + return nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Played audio file: %s", filePath), + }, + }, + }, nil + }) + + // Set voice tool + setVoiceTool := mcp.Tool{ + Name: "set_voice", + Description: "Set the voice to use for text-to-speech generation", + InputSchema: mcp.ToolInputSchema{ + Type: "object", + Properties: map[string]any{ + "voice_id": map[string]any{ + "type": "string", + "description": "ID of the voice to use", + }, + }, + Required: []string{"voice_id"}, + }, + } + + mcpServer.AddTool(setVoiceTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + voiceID, err := request.RequireString("voice_id") + if err != nil { + return nil, err + } + + s.voicesMutex.Lock() + defer s.voicesMutex.Unlock() + + // Find the voice + var selectedVoice *types.VoiceResponseModel + for i, voice := range s.voices { + if voice.VoiceID == voiceID { + selectedVoice = &s.voices[i] + break + } + } + + if selectedVoice == nil { + return nil, fmt.Errorf("voice with ID '%s' not found", voiceID) + } + + s.currentVoice = selectedVoice + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Voice set to: %s (%s)", selectedVoice.Name, selectedVoice.VoiceID), + }, + }, + }, nil + }) + + // Get voices tool + getVoicesTool := mcp.Tool{ + Name: "get_voices", + Description: "Get list of available voices and show the currently selected one", + InputSchema: mcp.ToolInputSchema{ + Type: "object", + Properties: map[string]any{}, + }, + } + + mcpServer.AddTool(getVoicesTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Refresh voices from API + if err := s.refreshVoices(); err != nil { + return nil, err + } + + s.voicesMutex.RLock() + defer s.voicesMutex.RUnlock() + + var voiceList strings.Builder + voiceList.WriteString("Available voices:\n") + + for _, voice := range s.voices { + marker := " " + if s.currentVoice != nil && voice.VoiceID == s.currentVoice.VoiceID { + marker = "* " + } + voiceList.WriteString(fmt.Sprintf("%s%s (%s) - %s\n", + marker, voice.Name, voice.VoiceID, voice.Category)) + } + + if s.currentVoice != nil { + voiceList.WriteString(fmt.Sprintf("\nCurrently selected: %s (%s)", + s.currentVoice.Name, s.currentVoice.VoiceID)) + } else { + voiceList.WriteString("\nNo voice currently selected") + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: voiceList.String(), + }, + }, + }, nil + }) + + // History tool + historyTool := mcp.Tool{ + Name: "history", + Description: "List available audio files with text summaries", + InputSchema: mcp.ToolInputSchema{ + Type: "object", + Properties: map[string]any{}, + }, + } + + mcpServer.AddTool(historyTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Read .xi directory + files, err := os.ReadDir(".xi") + if err != nil { + if os.IsNotExist(err) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: "No audio files found (directory doesn't exist yet)", + }, + }, + }, nil + } + return nil, fmt.Errorf("failed to read .xi directory: %w", err) + } + + var audioFiles []string + for _, file := range files { + if strings.HasSuffix(file.Name(), ".mp3") { + audioFiles = append(audioFiles, file.Name()) + } + } + + if len(audioFiles) == 0 { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: "No audio files found", + }, + }, + }, nil + } + + var historyList strings.Builder + historyList.WriteString("Available audio files:\n\n") + + for _, audioFile := range audioFiles { + // Try to read corresponding text file + textFile := strings.TrimSuffix(audioFile, ".mp3") + ".txt" + textPath := filepath.Join(".xi", textFile) + + summary := "" + if content, err := os.ReadFile(textPath); err == nil { + text := strings.TrimSpace(string(content)) + words := strings.Fields(text) + if len(words) > 10 { + summary = strings.Join(words[:10], " ") + "..." + } else { + summary = text + } + } else { + summary = "(no text summary available)" + } + + historyList.WriteString(fmt.Sprintf("• %s\n %s\n\n", audioFile, summary)) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: historyList.String(), + }, + }, + }, nil + }) +} + +func main() { + // Create ElevenLabs server + elevenServer, err := NewElevenLabsServer() + if err != nil { + log.Fatalf("Failed to create ElevenLabs server: %v", err) + } + + // Create MCP server + mcpServer := server.NewMCPServer( + "ElevenLabs MCP Server", + "1.0.0", + server.WithToolCapabilities(true), + ) + + // Setup tools + elevenServer.setupTools(mcpServer) + + // Serve via stdio + if err := server.ServeStdio(mcpServer); err != nil { + log.Fatalf("Failed to serve MCP server: %v", err) + } +} +