Commit a054b84
Changed files (7)
cmd
speech
pkg
speech
test
cmd/speech/main.go
@@ -0,0 +1,134 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+
+ "github.com/xlgmokha/mcp/pkg/speech"
+)
+
+func main() {
+ var showHelp bool
+ flag.BoolVar(&showHelp, "help", false, "Show help information")
+ flag.Parse()
+
+ if showHelp {
+ showHelpText()
+ return
+ }
+
+ server := speech.NewServer()
+ if err := server.Run(context.Background()); err != nil {
+ log.Fatalf("Server error: %v", err)
+ }
+}
+
+func showHelpText() {
+ fmt.Printf(`Speech MCP Server
+
+A Model Context Protocol server that provides text-to-speech capabilities using
+the macOS 'say' command. Enables LLMs to speak their responses with customizable
+voices, rates, and output options.
+
+USAGE:
+ mcp-speech [OPTIONS]
+
+OPTIONS:
+ --help Show this help message
+
+TOOLS PROVIDED:
+
+Speech Synthesis:
+ say Speak text with customizable voice, rate, and volume
+ list_voices List all available system voices with filtering
+ speak_file Read and speak the contents of a text file
+ stop_speech Stop any currently playing speech synthesis
+ speech_settings Get detailed information about speech options
+
+EXAMPLES:
+
+Basic Speech:
+ # Simple text-to-speech
+ {"name": "say", "arguments": {"text": "Hello, this is a test"}}
+
+ # Custom voice and speed
+ {"name": "say", "arguments": {"text": "Hello world", "voice": "Samantha", "rate": 150}}
+
+ # Adjust volume
+ {"name": "say", "arguments": {"text": "Quiet speech", "volume": 0.3}}
+
+Voice Management:
+ # List all voices
+ {"name": "list_voices", "arguments": {}}
+
+ # List English voices only
+ {"name": "list_voices", "arguments": {"language": "en"}}
+
+ # Get detailed voice information
+ {"name": "list_voices", "arguments": {"detailed": true}}
+
+File Operations:
+ # Speak file contents
+ {"name": "speak_file", "arguments": {"file_path": "/path/to/document.txt"}}
+
+ # Speak first 10 lines only
+ {"name": "speak_file", "arguments": {"file_path": "README.md", "max_lines": 10}}
+
+Audio Output:
+ # Save speech to file
+ {"name": "say", "arguments": {"text": "Recording test", "output": "~/speech.wav"}}
+
+Control:
+ # Stop any playing speech
+ {"name": "stop_speech", "arguments": {}}
+
+ # Get help with settings
+ {"name": "speech_settings", "arguments": {}}
+
+VOICE OPTIONS:
+Popular built-in voices include:
+ • Alex (default male voice)
+ • Samantha (clear female voice)
+ • Victoria (British female voice)
+ • Fred (older male voice)
+ • Fiona (Scottish female voice)
+ • Moira (Irish female voice)
+
+PARAMETERS:
+ text - Text to speak (required for 'say')
+ voice - Voice name (use list_voices to see options)
+ rate - Speech rate in words per minute (80-500, default ~200)
+ volume - Volume level from 0.0 to 1.0 (default: system volume)
+ output - Save audio to file (.aiff, .wav, .m4a formats)
+ file_path - Path to text file to speak
+ max_lines - Limit number of lines to speak from file
+ language - Filter voices by language code (e.g., "en", "es")
+ detailed - Show detailed voice information
+
+INTEGRATION:
+Add to your Claude Code configuration (~/.claude.json):
+
+{
+ "mcpServers": {
+ "speech": {
+ "command": "mcp-speech"
+ }
+ }
+}
+
+USAGE WITH GOOSE:
+Once integrated, you can ask Goose to speak responses:
+ "Say your response out loud using the speech tool"
+ "Read this file aloud using a female voice"
+ "List all available voices on my system"
+ "Stop any speech that's currently playing"
+
+REQUIREMENTS:
+- macOS (uses the built-in 'say' command)
+- Appropriate system permissions for audio output
+
+For support or issues, see: https://github.com/xlgmokha/mcp
+`)
+}
\ No newline at end of file
pkg/speech/server.go
@@ -0,0 +1,384 @@
+package speech
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/xlgmokha/mcp/pkg/mcp"
+)
+
+// Server represents the Speech MCP server
+type Server struct {
+ *mcp.Server
+ mu sync.RWMutex
+}
+
+// NewServer creates a new Speech MCP server
+func NewServer() *Server {
+ baseServer := mcp.NewServer("mcp-speech", "1.0.0")
+
+ server := &Server{
+ Server: baseServer,
+ }
+
+ // Register speech tools
+ server.RegisterTool("say", server.handleSay)
+ server.RegisterTool("list_voices", server.handleListVoices)
+ server.RegisterTool("speak_file", server.handleSpeakFile)
+ server.RegisterTool("stop_speech", server.handleStopSpeech)
+ server.RegisterTool("speech_settings", server.handleSpeechSettings)
+
+ return server
+}
+
+// handleSay speaks the provided text using the system TTS
+func (s *Server) handleSay(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ var args struct {
+ Text string `json:"text"`
+ Voice string `json:"voice,omitempty"`
+ Rate *int `json:"rate,omitempty"` // Words per minute (80-500)
+ Volume *float64 `json:"volume,omitempty"` // 0.0 to 1.0
+ Output string `json:"output,omitempty"` // File to save audio to
+ }
+
+ argsBytes, _ := json.Marshal(req.Arguments)
+ if err := json.Unmarshal(argsBytes, &args); err != nil {
+ return mcp.CallToolResult{}, fmt.Errorf("invalid arguments: %w", err)
+ }
+
+ if args.Text == "" {
+ return mcp.CallToolResult{}, fmt.Errorf("text is required")
+ }
+
+ // Check if we're on macOS (say command is macOS specific)
+ if runtime.GOOS != "darwin" {
+ return mcp.CallToolResult{}, fmt.Errorf("speech synthesis is only supported on macOS")
+ }
+
+ // Build say command
+ cmdArgs := []string{}
+
+ if args.Voice != "" {
+ cmdArgs = append(cmdArgs, "-v", args.Voice)
+ }
+
+ if args.Rate != nil {
+ if *args.Rate < 80 || *args.Rate > 500 {
+ return mcp.CallToolResult{}, fmt.Errorf("rate must be between 80-500 words per minute")
+ }
+ cmdArgs = append(cmdArgs, "-r", strconv.Itoa(*args.Rate))
+ }
+
+ if args.Volume != nil {
+ if *args.Volume < 0.0 || *args.Volume > 1.0 {
+ return mcp.CallToolResult{}, fmt.Errorf("volume must be between 0.0 and 1.0")
+ }
+ // Convert to 0-100 scale for say command
+ volume := int(*args.Volume * 100)
+ cmdArgs = append(cmdArgs, "--volume", strconv.Itoa(volume))
+ }
+
+ if args.Output != "" {
+ // Validate output file extension
+ ext := strings.ToLower(filepath.Ext(args.Output))
+ if ext != ".aiff" && ext != ".wav" && ext != ".m4a" {
+ return mcp.CallToolResult{}, fmt.Errorf("output format must be .aiff, .wav, or .m4a")
+ }
+ cmdArgs = append(cmdArgs, "-o", args.Output)
+ }
+
+ // Add the text to speak
+ cmdArgs = append(cmdArgs, args.Text)
+
+ cmd := exec.Command("say", cmdArgs...)
+ output, err := cmd.CombinedOutput()
+
+ var result string
+ if args.Output != "" {
+ result = fmt.Sprintf("Command: say %s\nAudio saved to: %s",
+ strings.Join(cmdArgs[:len(cmdArgs)-1], " "), args.Output)
+ } else {
+ result = fmt.Sprintf("Command: say %s\nSpoke: \"%s\"",
+ strings.Join(cmdArgs[:len(cmdArgs)-1], " "), args.Text)
+ }
+
+ if len(output) > 0 {
+ result += fmt.Sprintf("\nOutput: %s", string(output))
+ }
+
+ if err != nil {
+ result += fmt.Sprintf("\nError: %v", err)
+ }
+
+ return mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: result,
+ },
+ },
+ }, nil
+}
+
+// handleListVoices lists all available system voices
+func (s *Server) handleListVoices(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ var args struct {
+ Language string `json:"language,omitempty"` // Filter by language code (e.g., "en", "es")
+ Detailed bool `json:"detailed,omitempty"` // Include detailed voice information
+ }
+
+ argsBytes, _ := json.Marshal(req.Arguments)
+ if err := json.Unmarshal(argsBytes, &args); err != nil {
+ return mcp.CallToolResult{}, fmt.Errorf("invalid arguments: %w", err)
+ }
+
+ if runtime.GOOS != "darwin" {
+ return mcp.CallToolResult{}, fmt.Errorf("voice listing is only supported on macOS")
+ }
+
+ cmd := exec.Command("say", "-v", "?")
+ output, err := cmd.Output()
+
+ if err != nil {
+ return mcp.CallToolResult{}, fmt.Errorf("failed to list voices: %v", err)
+ }
+
+ voices := string(output)
+ var result strings.Builder
+
+ result.WriteString("Available voices:\n\n")
+
+ lines := strings.Split(voices, "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+
+ // Filter by language if specified
+ if args.Language != "" {
+ if !strings.Contains(strings.ToLower(line), args.Language) {
+ continue
+ }
+ }
+
+ if args.Detailed {
+ result.WriteString(line)
+ result.WriteString("\n")
+ } else {
+ // Extract just the voice name (first word)
+ parts := strings.Fields(line)
+ if len(parts) > 0 {
+ result.WriteString("• ")
+ result.WriteString(parts[0])
+ result.WriteString("\n")
+ }
+ }
+ }
+
+ return mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: result.String(),
+ },
+ },
+ }, nil
+}
+
+// handleSpeakFile speaks the contents of a text file
+func (s *Server) handleSpeakFile(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ var args struct {
+ FilePath string `json:"file_path"`
+ Voice string `json:"voice,omitempty"`
+ Rate *int `json:"rate,omitempty"`
+ Volume *float64 `json:"volume,omitempty"`
+ MaxLines *int `json:"max_lines,omitempty"` // Limit lines to speak
+ }
+
+ argsBytes, _ := json.Marshal(req.Arguments)
+ if err := json.Unmarshal(argsBytes, &args); err != nil {
+ return mcp.CallToolResult{}, fmt.Errorf("invalid arguments: %w", err)
+ }
+
+ if args.FilePath == "" {
+ return mcp.CallToolResult{}, fmt.Errorf("file_path is required")
+ }
+
+ if runtime.GOOS != "darwin" {
+ return mcp.CallToolResult{}, fmt.Errorf("speech synthesis is only supported on macOS")
+ }
+
+ // Read the file
+ content, err := os.ReadFile(args.FilePath)
+ if err != nil {
+ return mcp.CallToolResult{}, fmt.Errorf("failed to read file: %v", err)
+ }
+
+ text := string(content)
+
+ // Limit lines if specified
+ if args.MaxLines != nil && *args.MaxLines > 0 {
+ lines := strings.Split(text, "\n")
+ if len(lines) > *args.MaxLines {
+ lines = lines[:*args.MaxLines]
+ text = strings.Join(lines, "\n")
+ }
+ }
+
+ // Build say command
+ cmdArgs := []string{}
+
+ if args.Voice != "" {
+ cmdArgs = append(cmdArgs, "-v", args.Voice)
+ }
+
+ if args.Rate != nil {
+ if *args.Rate < 80 || *args.Rate > 500 {
+ return mcp.CallToolResult{}, fmt.Errorf("rate must be between 80-500 words per minute")
+ }
+ cmdArgs = append(cmdArgs, "-r", strconv.Itoa(*args.Rate))
+ }
+
+ if args.Volume != nil {
+ if *args.Volume < 0.0 || *args.Volume > 1.0 {
+ return mcp.CallToolResult{}, fmt.Errorf("volume must be between 0.0 and 1.0")
+ }
+ volume := int(*args.Volume * 100)
+ cmdArgs = append(cmdArgs, "--volume", strconv.Itoa(volume))
+ }
+
+ cmdArgs = append(cmdArgs, "-f", args.FilePath)
+
+ cmd := exec.Command("say", cmdArgs...)
+ output, err := cmd.CombinedOutput()
+
+ linesCount := len(strings.Split(text, "\n"))
+ wordsCount := len(strings.Fields(text))
+
+ result := fmt.Sprintf("Command: say %s\nSpeaking file: %s\nLines: %d, Words: %d",
+ strings.Join(cmdArgs, " "), args.FilePath, linesCount, wordsCount)
+
+ if args.MaxLines != nil {
+ result += fmt.Sprintf(" (limited to %d lines)", *args.MaxLines)
+ }
+
+ if len(output) > 0 {
+ result += fmt.Sprintf("\nOutput: %s", string(output))
+ }
+
+ if err != nil {
+ result += fmt.Sprintf("\nError: %v", err)
+ }
+
+ return mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: result,
+ },
+ },
+ }, nil
+}
+
+// handleStopSpeech stops any currently playing speech
+func (s *Server) handleStopSpeech(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ if runtime.GOOS != "darwin" {
+ return mcp.CallToolResult{}, fmt.Errorf("speech control is only supported on macOS")
+ }
+
+ // Kill any running say processes
+ cmd := exec.Command("pkill", "say")
+ err := cmd.Run()
+
+ result := "Stopped all speech synthesis"
+ if err != nil {
+ // pkill returns error if no processes found, which is fine
+ result += " (no speech processes were running)"
+ }
+
+ return mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: result,
+ },
+ },
+ }, nil
+}
+
+// handleSpeechSettings provides information about speech synthesis settings
+func (s *Server) handleSpeechSettings(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ if runtime.GOOS != "darwin" {
+ return mcp.CallToolResult{}, fmt.Errorf("speech settings are only supported on macOS")
+ }
+
+ result := `Speech Synthesis Settings and Usage:
+
+VOICES:
+• Use 'list_voices' tool to see all available voices
+• Popular voices: Alex, Samantha, Victoria, Fred, Fiona
+• Specify with: {"voice": "Alex"}
+
+RATE (Speed):
+• Range: 80-500 words per minute
+• Default: ~200 wpm
+• Specify with: {"rate": 150}
+
+VOLUME:
+• Range: 0.0 (silent) to 1.0 (maximum)
+• Default: system volume
+• Specify with: {"volume": 0.8}
+
+OUTPUT FORMATS:
+• Save to file: .aiff, .wav, .m4a
+• Specify with: {"output": "/path/to/file.wav"}
+
+EXAMPLES:
+1. Basic speech:
+ {"text": "Hello, this is a test"}
+
+2. Custom voice and speed:
+ {"text": "Hello world", "voice": "Samantha", "rate": 120}
+
+3. Save to file:
+ {"text": "Recording test", "output": "~/speech.wav"}
+
+4. Speak file contents:
+ {"file_path": "~/document.txt", "max_lines": 10}
+
+CONTROLS:
+• Use 'stop_speech' to interrupt any playing speech
+• Multiple speech commands will queue automatically`
+
+ return mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: result,
+ },
+ },
+ }, nil
+}
\ No newline at end of file
pkg/speech/server_test.go
@@ -0,0 +1,404 @@
+package speech
+
+import (
+ "encoding/json"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/xlgmokha/mcp/pkg/mcp"
+)
+
+func TestNewServer(t *testing.T) {
+ server := NewServer()
+ if server == nil {
+ t.Fatal("NewServer returned nil")
+ }
+
+ if server.Server == nil {
+ t.Fatal("Base server is nil")
+ }
+}
+
+func TestHandleSayValidation(t *testing.T) {
+ server := NewServer()
+
+ tests := []struct {
+ name string
+ args map[string]interface{}
+ expectError bool
+ errorContains string
+ }{
+ {
+ name: "empty text",
+ args: map[string]interface{}{},
+ expectError: true,
+ errorContains: "text is required",
+ },
+ {
+ name: "invalid rate too low",
+ args: map[string]interface{}{
+ "text": "test",
+ "rate": 50,
+ },
+ expectError: true,
+ errorContains: "rate must be between 80-500",
+ },
+ {
+ name: "invalid rate too high",
+ args: map[string]interface{}{
+ "text": "test",
+ "rate": 600,
+ },
+ expectError: true,
+ errorContains: "rate must be between 80-500",
+ },
+ {
+ name: "invalid volume too low",
+ args: map[string]interface{}{
+ "text": "test",
+ "volume": -0.1,
+ },
+ expectError: true,
+ errorContains: "volume must be between 0.0 and 1.0",
+ },
+ {
+ name: "invalid volume too high",
+ args: map[string]interface{}{
+ "text": "test",
+ "volume": 1.1,
+ },
+ expectError: true,
+ errorContains: "volume must be between 0.0 and 1.0",
+ },
+ {
+ name: "invalid output format",
+ args: map[string]interface{}{
+ "text": "test",
+ "output": "test.mp3",
+ },
+ expectError: true,
+ errorContains: "output format must be .aiff, .wav, or .m4a",
+ },
+ {
+ name: "valid basic args",
+ args: map[string]interface{}{
+ "text": "Hello world",
+ },
+ expectError: runtime.GOOS != "darwin", // Should only work on macOS
+ },
+ {
+ name: "valid complex args",
+ args: map[string]interface{}{
+ "text": "Hello world",
+ "voice": "Samantha",
+ "rate": 150,
+ "volume": 0.8,
+ },
+ expectError: runtime.GOOS != "darwin",
+ },
+ {
+ name: "valid output file",
+ args: map[string]interface{}{
+ "text": "test recording",
+ "output": "/tmp/test.wav",
+ },
+ expectError: runtime.GOOS != "darwin",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := mcp.CallToolRequest{
+ Name: "say",
+ Arguments: tt.args,
+ }
+
+ result, err := server.handleSay(req)
+
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ } else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
+ t.Errorf("Expected error to contain %q, got %q", tt.errorContains, err.Error())
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ if len(result.Content) == 0 {
+ t.Errorf("Expected content in result")
+ }
+ }
+ })
+ }
+}
+
+func TestHandleListVoices(t *testing.T) {
+ server := NewServer()
+
+ tests := []struct {
+ name string
+ args map[string]interface{}
+ expectError bool
+ }{
+ {
+ name: "basic list",
+ args: map[string]interface{}{},
+ expectError: runtime.GOOS != "darwin",
+ },
+ {
+ name: "with language filter",
+ args: map[string]interface{}{
+ "language": "en",
+ },
+ expectError: runtime.GOOS != "darwin",
+ },
+ {
+ name: "detailed mode",
+ args: map[string]interface{}{
+ "detailed": true,
+ },
+ expectError: runtime.GOOS != "darwin",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := mcp.CallToolRequest{
+ Name: "list_voices",
+ Arguments: tt.args,
+ }
+
+ result, err := server.handleListVoices(req)
+
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ if len(result.Content) == 0 {
+ t.Errorf("Expected content in result")
+ }
+ }
+ })
+ }
+}
+
+func TestHandleSpeakFileValidation(t *testing.T) {
+ server := NewServer()
+
+ tests := []struct {
+ name string
+ args map[string]interface{}
+ expectError bool
+ errorContains string
+ }{
+ {
+ name: "empty file path",
+ args: map[string]interface{}{},
+ expectError: true,
+ errorContains: "file_path is required",
+ },
+ {
+ name: "nonexistent file",
+ args: map[string]interface{}{
+ "file_path": "/nonexistent/file.txt",
+ },
+ expectError: true,
+ errorContains: "failed to read file",
+ },
+ {
+ name: "invalid rate",
+ args: map[string]interface{}{
+ "file_path": "/etc/passwd", // Use a file that exists
+ "rate": 1000,
+ },
+ expectError: true,
+ errorContains: "rate must be between 80-500",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := mcp.CallToolRequest{
+ Name: "speak_file",
+ Arguments: tt.args,
+ }
+
+ result, err := server.handleSpeakFile(req)
+
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ } else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
+ t.Errorf("Expected error to contain %q, got %q", tt.errorContains, err.Error())
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ if len(result.Content) == 0 {
+ t.Errorf("Expected content in result")
+ }
+ }
+ })
+ }
+}
+
+func TestHandleStopSpeech(t *testing.T) {
+ server := NewServer()
+
+ req := mcp.CallToolRequest{
+ Name: "stop_speech",
+ Arguments: map[string]interface{}{},
+ }
+
+ result, err := server.handleStopSpeech(req)
+
+ if runtime.GOOS != "darwin" {
+ if err == nil {
+ t.Errorf("Expected error on non-macOS platform")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+
+ if len(result.Content) == 0 {
+ t.Errorf("Expected content in result")
+ }
+
+ // Check that result contains stop message
+ content := result.Content[0]
+ if textContent, ok := content.(mcp.TextContent); ok {
+ if !strings.Contains(textContent.Text, "Stopped") {
+ t.Errorf("Expected stop message in result, got: %s", textContent.Text)
+ }
+ } else {
+ t.Errorf("Expected TextContent, got %T", content)
+ }
+}
+
+func TestHandleSpeechSettings(t *testing.T) {
+ server := NewServer()
+
+ req := mcp.CallToolRequest{
+ Name: "speech_settings",
+ Arguments: map[string]interface{}{},
+ }
+
+ result, err := server.handleSpeechSettings(req)
+
+ if runtime.GOOS != "darwin" {
+ if err == nil {
+ t.Errorf("Expected error on non-macOS platform")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+
+ if len(result.Content) == 0 {
+ t.Errorf("Expected content in result")
+ }
+
+ // Check that result contains settings information
+ content := result.Content[0]
+ if textContent, ok := content.(mcp.TextContent); ok {
+ settingsText := textContent.Text
+ expectedSections := []string{
+ "VOICES:",
+ "RATE (Speed):",
+ "VOLUME:",
+ "OUTPUT FORMATS:",
+ "EXAMPLES:",
+ "CONTROLS:",
+ }
+
+ for _, section := range expectedSections {
+ if !strings.Contains(settingsText, section) {
+ t.Errorf("Expected settings to contain section %q", section)
+ }
+ }
+ } else {
+ t.Errorf("Expected TextContent, got %T", content)
+ }
+}
+
+func TestJSONArguments(t *testing.T) {
+ server := NewServer()
+
+ // Test that arguments are properly marshaled/unmarshaled
+ args := map[string]interface{}{
+ "text": "Hello world",
+ "voice": "Samantha",
+ "rate": 150,
+ "volume": 0.8,
+ }
+
+ // Marshal to JSON and back to simulate real request
+ argsBytes, err := json.Marshal(args)
+ if err != nil {
+ t.Fatalf("Failed to marshal args: %v", err)
+ }
+
+ var unmarshaled map[string]interface{}
+ if err := json.Unmarshal(argsBytes, &unmarshaled); err != nil {
+ t.Fatalf("Failed to unmarshal args: %v", err)
+ }
+
+ req := mcp.CallToolRequest{
+ Name: "say",
+ Arguments: unmarshaled,
+ }
+
+ // This should not panic or return invalid argument errors
+ _, err = server.handleSay(req)
+
+ // Error is expected on non-macOS, but should not be argument-related
+ if err != nil && runtime.GOOS == "darwin" {
+ // On macOS, any error should not be about invalid arguments
+ if strings.Contains(err.Error(), "invalid arguments") {
+ t.Errorf("Argument parsing failed: %v", err)
+ }
+ }
+}
+
+func TestMacOSOnlyFunctionality(t *testing.T) {
+ if runtime.GOOS == "darwin" {
+ t.Skip("Skipping non-macOS test on macOS")
+ }
+
+ server := NewServer()
+
+ tools := []string{"say", "list_voices", "speak_file", "stop_speech", "speech_settings"}
+
+ for _, toolName := range tools {
+ t.Run(toolName, func(t *testing.T) {
+ req := mcp.CallToolRequest{
+ Name: toolName,
+ Arguments: map[string]interface{}{
+ "text": "test", // Required for say and speak_file
+ "file_path": "/tmp/test.txt", // Required for speak_file
+ },
+ }
+
+ _, err := server.handleSay(req)
+ if err == nil {
+ t.Errorf("Expected macOS-only error for tool %s", toolName)
+ }
+
+ if !strings.Contains(err.Error(), "macOS") {
+ t.Errorf("Expected macOS-specific error message, got: %v", err)
+ }
+ })
+ }
+}
\ No newline at end of file
test/integration_test.go
@@ -96,6 +96,16 @@ func TestMCPServersIntegration(t *testing.T) {
Args: []string{"--gitlab-token", "fake_token_for_testing"},
Name: "gitlab",
},
+ {
+ Binary: "../bin/mcp-packages",
+ Args: []string{},
+ Name: "packages",
+ },
+ {
+ Binary: "../bin/mcp-speech",
+ Args: []string{},
+ Name: "speech",
+ },
}
for _, server := range servers {
.mcp.json
@@ -0,0 +1,18 @@
+{
+ "mcpServers": {
+ "serena": {
+ "type": "stdio",
+ "command": "uvx",
+ "args": [
+ "--from",
+ "git+https://github.com/oraios/serena",
+ "serena-mcp-server",
+ "--context",
+ "ide-assistant",
+ "--project",
+ "/Users/xlgmokha/src/github.com/xlgmokha/mcp"
+ ],
+ "env": {}
+ }
+ }
+}
\ No newline at end of file
Makefile
@@ -11,7 +11,7 @@ BINDIR = bin
INSTALLDIR = /usr/local/bin
# Server binaries
-SERVERS = git filesystem fetch memory sequential-thinking time maildir signal gitlab imap bash packages
+SERVERS = git filesystem fetch memory sequential-thinking time maildir signal gitlab imap bash packages speech
BINARIES = $(addprefix $(BINDIR)/mcp-,$(SERVERS))
# Build flags
README.md
@@ -22,8 +22,10 @@ A pure Go implementation of Model Context Protocol (MCP) servers, providing drop
| **imap** | IMAP email server connectivity for Gmail, Migadu, etc. | Complete |
| **maildir** | Email archive analysis for maildir format | Complete |
| **memory** | Knowledge graph persistent memory system | Complete |
+| **packages** | Package management for Cargo, Homebrew, and more | Complete |
| **sequential-thinking** | Dynamic problem-solving with thought sequences | Complete |
| **signal** | Signal Desktop database access with encrypted SQLCipher | Complete |
+| **speech** | Text-to-speech synthesis using macOS say command | Complete |
| **time** | Time and timezone conversion utilities | Complete |
## Quick Start
@@ -69,9 +71,15 @@ Replace Python MCP servers in your `~/.claude.json` configuration:
"memory": {
"command": "mcp-memory"
},
+ "packages": {
+ "command": "mcp-packages"
+ },
"sequential-thinking": {
"command": "mcp-sequential-thinking"
},
+ "speech": {
+ "command": "mcp-speech"
+ },
"time": {
"command": "mcp-time"
}
@@ -237,6 +245,65 @@ mcp-sequential-thinking
mcp-sequential-thinking --session-file /path/to/sessions.json
```
+### Packages Server (`mcp-packages`)
+
+Package management tools for multiple ecosystems including Rust (Cargo) and macOS (Homebrew).
+
+**Cargo Tools:**
+- `cargo_build` - Build Rust projects with optional flags
+- `cargo_run` - Run Rust applications with arguments
+- `cargo_test` - Execute test suites with filtering
+- `cargo_add` - Add dependencies to Cargo.toml
+- `cargo_update` - Update dependencies to latest versions
+- `cargo_check` - Quick compile check without building
+- `cargo_clippy` - Run Rust linter with suggestions
+
+**Homebrew Tools:**
+- `brew_install` - Install packages or casks
+- `brew_uninstall` - Remove packages or casks
+- `brew_search` - Search for available packages
+- `brew_update` - Update Homebrew itself
+- `brew_upgrade` - Upgrade installed packages
+- `brew_doctor` - Check system health
+- `brew_list` - List installed packages
+
+**Cross-Platform Tools:**
+- `check_vulnerabilities` - Scan for security vulnerabilities
+- `outdated_packages` - Find packages needing updates
+- `package_info` - Get detailed package information
+
+**Usage:**
+```bash
+mcp-packages
+```
+
+### Speech Server (`mcp-speech`)
+
+Text-to-speech synthesis using the macOS `say` command. Enables LLMs to speak their responses with customizable voices and settings.
+
+**Tools:**
+- `say` - Speak text with customizable voice, rate, and volume
+- `list_voices` - List all available system voices (100+ voices)
+- `speak_file` - Read and speak the contents of a text file
+- `stop_speech` - Stop any currently playing speech synthesis
+- `speech_settings` - Get detailed information about speech options
+
+**Features:**
+- Voice selection from 100+ built-in voices (Alex, Samantha, Victoria, etc.)
+- Adjustable speech rate (80-500 words per minute)
+- Volume control (0.0 to 1.0)
+- Audio file output (.wav, .aiff, .m4a formats)
+- File reading with line limits
+- Instant speech control and interruption
+
+**Usage:**
+```bash
+mcp-speech
+```
+
+**Requirements:**
+- macOS (uses the built-in `say` command)
+
### Time Server (`mcp-time`)
Time and timezone utilities for temporal operations.