main
1package signal
2
3import (
4 "crypto/aes"
5 "crypto/cipher"
6 "crypto/sha1"
7 "database/sql"
8 "encoding/base64"
9 "encoding/hex"
10 "encoding/json"
11 "fmt"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "runtime"
16 "strconv"
17 "strings"
18 "sync"
19 "time"
20
21 "github.com/xlgmokha/mcp/pkg/mcp"
22 _ "github.com/mutecomm/go-sqlcipher/v4"
23 "golang.org/x/crypto/pbkdf2"
24)
25
26// SignalOperations provides Signal Desktop database operations
27type SignalOperations struct {
28 mu sync.RWMutex
29 dbPath string
30 configPath string
31 db *sql.DB
32}
33
34type SignalConfig struct {
35 Key string `json:"key"`
36 EncryptedKey string `json:"encryptedKey"`
37}
38
39type Contact struct {
40 ID string `json:"id"`
41 Name string `json:"name"`
42 ProfileName string `json:"profileName"`
43 Phone string `json:"phone"`
44}
45
46type Message struct {
47 ID string `json:"id"`
48 ConversationID string `json:"conversationId"`
49 Type string `json:"type"`
50 Body string `json:"body"`
51 From string `json:"from"`
52 To string `json:"to"`
53 SentAt time.Time `json:"sentAt"`
54 Attachments []Attachment `json:"attachments,omitempty"`
55 RawJSON map[string]interface{} `json:"rawJson,omitempty"`
56}
57
58type Attachment struct {
59 FileName string `json:"filename"`
60 Path string `json:"path"`
61 ContentType string `json:"contentType,omitempty"`
62 Size int64 `json:"size,omitempty"`
63}
64
65// NewSignalOperations creates a new SignalOperations helper
66func NewSignalOperations(signalPath string) (*SignalOperations, error) {
67 signal := &SignalOperations{}
68
69 // Determine Signal database and config paths
70 if signalPath != "" {
71 signal.dbPath = filepath.Join(signalPath, "sql", "db.sqlite")
72 signal.configPath = filepath.Join(signalPath, "config.json")
73 } else {
74 var err error
75 signal.dbPath, signal.configPath, err = findSignalPaths()
76 if err != nil {
77 return nil, fmt.Errorf("failed to find Signal paths: %w", err)
78 }
79 }
80
81 return signal, nil
82}
83
84// New creates a new Signal MCP server
85func New(signalPath string) (*mcp.Server, error) {
86 signal, err := NewSignalOperations(signalPath)
87 if err != nil {
88 return nil, err
89 }
90
91 builder := mcp.NewServerBuilder("signal-server", "1.0.0")
92
93 // Add signal_list_conversations tool
94 builder.AddTool(mcp.NewTool("signal_list_conversations", "List recent Signal conversations with timestamps and participants", map[string]interface{}{
95 "type": "object",
96 "properties": map[string]interface{}{
97 "limit": map[string]interface{}{
98 "type": "integer",
99 "description": "Maximum number of conversations to return",
100 "minimum": 1,
101 "default": 20,
102 },
103 },
104 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
105 return signal.handleListConversations(req)
106 }))
107
108 // Add signal_search_messages tool
109 builder.AddTool(mcp.NewTool("signal_search_messages", "Search Signal messages by content with filtering options", map[string]interface{}{
110 "type": "object",
111 "properties": map[string]interface{}{
112 "query": map[string]interface{}{
113 "type": "string",
114 "description": "Search query string",
115 },
116 "conversation_id": map[string]interface{}{
117 "type": "string",
118 "description": "Optional conversation ID to limit search scope",
119 },
120 "limit": map[string]interface{}{
121 "type": "integer",
122 "description": "Maximum number of results",
123 "minimum": 1,
124 "default": 50,
125 },
126 },
127 "required": []string{"query"},
128 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
129 return signal.handleSearchMessages(req)
130 }))
131
132 // Add signal_get_conversation tool
133 builder.AddTool(mcp.NewTool("signal_get_conversation", "Get detailed conversation history including all messages and metadata", map[string]interface{}{
134 "type": "object",
135 "properties": map[string]interface{}{
136 "conversation_id": map[string]interface{}{
137 "type": "string",
138 "description": "The conversation ID to retrieve",
139 },
140 "limit": map[string]interface{}{
141 "type": "integer",
142 "description": "Maximum number of messages to return",
143 "minimum": 1,
144 "default": 100,
145 },
146 },
147 "required": []string{"conversation_id"},
148 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
149 return signal.handleGetConversation(req)
150 }))
151
152 // Add signal_get_contact tool
153 builder.AddTool(mcp.NewTool("signal_get_contact", "Get detailed contact information by ID, phone number, or name", map[string]interface{}{
154 "type": "object",
155 "properties": map[string]interface{}{
156 "contact_id": map[string]interface{}{
157 "type": "string",
158 "description": "Contact ID, phone number, or display name",
159 },
160 },
161 "required": []string{"contact_id"},
162 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
163 return signal.handleGetContact(req)
164 }))
165
166 // Add signal_get_message tool
167 builder.AddTool(mcp.NewTool("signal_get_message", "Get detailed information about a specific message including attachments and reactions", map[string]interface{}{
168 "type": "object",
169 "properties": map[string]interface{}{
170 "message_id": map[string]interface{}{
171 "type": "string",
172 "description": "The message ID to retrieve",
173 },
174 },
175 "required": []string{"message_id"},
176 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
177 return signal.handleGetMessage(req)
178 }))
179
180 // Add signal_list_attachments tool
181 builder.AddTool(mcp.NewTool("signal_list_attachments", "List message attachments with metadata and filtering options", map[string]interface{}{
182 "type": "object",
183 "properties": map[string]interface{}{
184 "conversation_id": map[string]interface{}{
185 "type": "string",
186 "description": "Optional conversation ID to filter attachments",
187 },
188 "media_type": map[string]interface{}{
189 "type": "string",
190 "description": "Filter by media type (image, video, audio, file)",
191 },
192 "limit": map[string]interface{}{
193 "type": "integer",
194 "description": "Maximum number of attachments to return",
195 "minimum": 1,
196 "default": 50,
197 },
198 },
199 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
200 return signal.handleListAttachments(req)
201 }))
202
203 // Add signal_get_stats tool
204 builder.AddTool(mcp.NewTool("signal_get_stats", "Get Signal database statistics and connection information", map[string]interface{}{
205 "type": "object",
206 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
207 return signal.handleGetStats(req)
208 }))
209
210 // Add prompts
211 builder.AddPrompt(mcp.NewPrompt("signal-conversation", "Analyze Signal conversation history for insights", []mcp.PromptArgument{}, func(req mcp.GetPromptRequest) (mcp.GetPromptResult, error) {
212 return signal.handleConversationPrompt(req)
213 }))
214
215 builder.AddPrompt(mcp.NewPrompt("signal-search", "Search Signal messages with context", []mcp.PromptArgument{}, func(req mcp.GetPromptRequest) (mcp.GetPromptResult, error) {
216 return signal.handleSearchPrompt(req)
217 }))
218
219 return builder.Build(), nil
220}
221
222
223func findSignalPaths() (dbPath, configPath string, err error) {
224 var basePath string
225
226 switch runtime.GOOS {
227 case "darwin":
228 basePath = filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "Signal")
229 case "linux":
230 // Try Flatpak first
231 flatpakPath := filepath.Join(os.Getenv("HOME"), ".var", "app", "org.signal.Signal", "config", "Signal")
232 if _, err := os.Stat(flatpakPath); err == nil {
233 basePath = flatpakPath
234 } else {
235 // Try snap
236 snapPath := filepath.Join(os.Getenv("HOME"), "snap", "signal-desktop", "current", ".config", "Signal")
237 if _, err := os.Stat(snapPath); err == nil {
238 basePath = snapPath
239 } else {
240 // Default Linux path
241 basePath = filepath.Join(os.Getenv("HOME"), ".config", "Signal")
242 }
243 }
244 case "windows":
245 basePath = filepath.Join(os.Getenv("APPDATA"), "Signal")
246 default:
247 return "", "", fmt.Errorf("unsupported platform: %s", runtime.GOOS)
248 }
249
250 dbPath = filepath.Join(basePath, "sql", "db.sqlite")
251 configPath = filepath.Join(basePath, "config.json")
252
253 if _, err := os.Stat(dbPath); os.IsNotExist(err) {
254 return "", "", fmt.Errorf("Signal database not found at %s", dbPath)
255 }
256 if _, err := os.Stat(configPath); os.IsNotExist(err) {
257 return "", "", fmt.Errorf("Signal config not found at %s", configPath)
258 }
259
260 return dbPath, configPath, nil
261}
262
263func (signal *SignalOperations) ensureConnection() error {
264 // Skip connection since we'll use command-line sqlcipher
265 return nil
266}
267
268func (signal *SignalOperations) executeQuery(query string) ([]byte, error) {
269 // Get decryption key - use the same method as working implementation
270 key, err := signal.getDecryptedSignalKey()
271 if err != nil {
272 return nil, fmt.Errorf("failed to get Signal key: %w", err)
273 }
274
275 // Create SQL script using the same parameters as working implementation
276 sqlScript := fmt.Sprintf(`
277PRAGMA KEY = "x'%s'";
278PRAGMA cipher_page_size = 4096;
279PRAGMA kdf_iter = 64000;
280PRAGMA cipher_hmac_algorithm = HMAC_SHA512;
281PRAGMA cipher_kdf_algorithm = PBKDF2_HMAC_SHA512;
282
283.mode json
284%s;
285`, key, query)
286
287 // Execute sqlcipher with the script
288 cmd := exec.Command("sqlcipher", signal.dbPath)
289 cmd.Stdin = strings.NewReader(sqlScript)
290 output, err := cmd.CombinedOutput()
291 if err != nil {
292 return nil, fmt.Errorf("sqlcipher command failed: %w\nOutput: %s", err, string(output))
293 }
294
295 // Remove the "ok" line that sqlcipher outputs and extract JSON
296 outputStr := string(output)
297 lines := strings.Split(outputStr, "\n")
298 var jsonContent []string
299
300 // Look for JSON array start
301 inJson := false
302 for _, line := range lines {
303 line = strings.TrimSpace(line)
304 if line == "ok" || line == "" {
305 continue
306 }
307 if strings.HasPrefix(line, "[") {
308 inJson = true
309 }
310 if inJson {
311 jsonContent = append(jsonContent, line)
312 }
313 }
314
315 if len(jsonContent) > 0 {
316 return []byte(strings.Join(jsonContent, "")), nil
317 }
318
319 return []byte("[]"), nil
320}
321
322func (signal *SignalOperations) getDecryptedSignalKey() (string, error) {
323 // Read config.json
324 configData, err := os.ReadFile(signal.configPath)
325 if err != nil {
326 return "", fmt.Errorf("failed to read config: %w", err)
327 }
328
329 var config SignalConfig
330 if err := json.Unmarshal(configData, &config); err != nil {
331 return "", fmt.Errorf("failed to parse config: %w", err)
332 }
333
334 if config.Key != "" {
335 return config.Key, nil
336 }
337
338 if config.EncryptedKey != "" {
339 return signal.decryptSignalKey(config.EncryptedKey)
340 }
341
342 return "", fmt.Errorf("no encryption key found in config")
343}
344
345func (signal *SignalOperations) decryptSignalKey(encryptedKey string) (string, error) {
346 switch runtime.GOOS {
347 case "darwin":
348 return signal.decryptKeyMacOS(encryptedKey)
349 case "linux":
350 return signal.decryptKeyLinux(encryptedKey)
351 default:
352 return "", fmt.Errorf("key decryption not supported on %s", runtime.GOOS)
353 }
354}
355
356func (signal *SignalOperations) decryptKeyMacOS(encryptedKey string) (string, error) {
357 // Get password from macOS Keychain using security command
358 cmd := exec.Command("security", "find-generic-password", "-ws", "Signal Safe Storage")
359 output, err := cmd.Output()
360 if err != nil {
361 return "", fmt.Errorf("failed to get Signal password from macOS Keychain: %w. "+
362 "Make sure Signal Desktop has been run at least once and you're logged in", err)
363 }
364
365 password := strings.TrimSpace(string(output))
366 if password == "" {
367 return "", fmt.Errorf("empty password retrieved from keychain")
368 }
369
370 // Decrypt the key using the password
371 key, err := signal.decryptWithPassword(password, encryptedKey, "v10", 1003)
372 if err != nil {
373 return "", fmt.Errorf("failed to decrypt key: %w", err)
374 }
375
376 return key, nil
377}
378
379func (signal *SignalOperations) decryptKeyLinux(encryptedKey string) (string, error) {
380 // Try secret-tool first
381 cmd := exec.Command("secret-tool", "lookup", "application", "Signal")
382 if output, err := cmd.Output(); err == nil {
383 password := strings.TrimSpace(string(output))
384 if password != "" {
385 return signal.decryptWithPassword(password, encryptedKey, "v11", 1)
386 }
387 }
388
389 // Could add KDE wallet support here
390 return "", fmt.Errorf("failed to decrypt key on Linux")
391}
392
393func (signal *SignalOperations) convertKeyToHex(key string) (string, error) {
394 // Signal stores keys in base64 format in the keychain
395 // SQLCipher expects hex format with x'...' syntax
396 keyBytes, err := base64.StdEncoding.DecodeString(key)
397 if err != nil {
398 return "", fmt.Errorf("failed to decode base64 key: %w", err)
399 }
400
401 return hex.EncodeToString(keyBytes), nil
402}
403
404func (signal *SignalOperations) decryptWithPassword(password, encryptedKey, prefix string, iterations int) (string, error) {
405 // Decode hex key
406 encryptedKeyBytes, err := hex.DecodeString(encryptedKey)
407 if err != nil {
408 return "", fmt.Errorf("failed to decode encrypted key: %w", err)
409 }
410
411 // Check prefix
412 prefixBytes := []byte(prefix)
413 if len(encryptedKeyBytes) < len(prefixBytes) || string(encryptedKeyBytes[:len(prefixBytes)]) != prefix {
414 return "", fmt.Errorf("encrypted key has wrong prefix, expected %s", prefix)
415 }
416
417 // Remove prefix
418 encryptedKeyBytes = encryptedKeyBytes[len(prefixBytes):]
419
420 // Derive decryption key using PBKDF2
421 salt := []byte("saltysalt")
422 key := pbkdf2.Key([]byte(password), salt, iterations, 16, sha1.New)
423
424 // Decrypt using AES-CBC
425 if len(encryptedKeyBytes) < aes.BlockSize {
426 return "", fmt.Errorf("encrypted key too short")
427 }
428
429 // Create cipher
430 block, err := aes.NewCipher(key)
431 if err != nil {
432 return "", fmt.Errorf("failed to create AES cipher: %w", err)
433 }
434
435 // Use 16 space bytes as IV (as per signal-export implementation: b" " * 16)
436 iv := make([]byte, aes.BlockSize)
437 for i := range iv {
438 iv[i] = ' ' // ASCII space character (32)
439 }
440 mode := cipher.NewCBCDecrypter(block, iv)
441
442 // Decrypt
443 decrypted := make([]byte, len(encryptedKeyBytes))
444 mode.CryptBlocks(decrypted, encryptedKeyBytes)
445
446 // Remove PKCS7 padding
447 decrypted, err = signal.removePKCS7Padding(decrypted)
448 if err != nil {
449 return "", fmt.Errorf("failed to remove padding: %w", err)
450 }
451
452 // Convert to string - should be ASCII hex characters
453 result := string(decrypted)
454
455 return result, nil
456}
457
458func (signal *SignalOperations) removePKCS7Padding(data []byte) ([]byte, error) {
459 if len(data) == 0 {
460 return nil, fmt.Errorf("empty data")
461 }
462
463 padLen := int(data[len(data)-1])
464 if padLen > len(data) || padLen == 0 {
465 return nil, fmt.Errorf("invalid padding")
466 }
467
468 // Check padding bytes
469 for i := len(data) - padLen; i < len(data); i++ {
470 if data[i] != byte(padLen) {
471 return nil, fmt.Errorf("invalid padding")
472 }
473 }
474
475 return data[:len(data)-padLen], nil
476}
477
478func (signal *SignalOperations) handleListConversations(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
479 if err := signal.ensureConnection(); err != nil {
480 return mcp.NewToolError(fmt.Sprintf("Database connection failed: %v", err)), nil
481 }
482
483 query := `SELECT id, COALESCE(name, profileName, e164, id) as display_name,
484 profileName, e164, type, active_at
485 FROM conversations
486 WHERE type IS NOT NULL
487 ORDER BY active_at DESC
488 LIMIT 10`
489
490 output, err := signal.executeQuery(query)
491 if err != nil {
492 return mcp.NewToolError(fmt.Sprintf("Query failed: %v", err)), nil
493 }
494
495 var conversations []map[string]interface{}
496 if err := json.Unmarshal(output, &conversations); err != nil {
497 return mcp.NewToolError(fmt.Sprintf("Failed to parse conversations: %v", err)), nil
498 }
499
500 result := fmt.Sprintf("Recent conversations (%d):\n\n", len(conversations))
501 for _, conv := range conversations {
502 displayName := conv["display_name"]
503 if displayName == nil {
504 displayName = "Unknown"
505 }
506 activeAt := conv["active_at"]
507 if activeAt != nil {
508 if timestamp, ok := activeAt.(float64); ok {
509 t := time.Unix(int64(timestamp/1000), 0)
510 result += fmt.Sprintf("• %s (last active: %s)\n", displayName, t.Format("2006-01-02 15:04"))
511 } else {
512 result += fmt.Sprintf("• %s\n", displayName)
513 }
514 } else {
515 result += fmt.Sprintf("• %s\n", displayName)
516 }
517 }
518
519 return mcp.NewToolResult(mcp.NewTextContent(result)), nil
520}
521
522func (signal *SignalOperations) handleSearchMessages(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
523 args := req.Arguments
524 searchTerm, ok := args["query"].(string)
525 if !ok || searchTerm == "" {
526 return mcp.NewToolError("query parameter is required"), nil
527 }
528
529 limitStr, _ := args["limit"].(string)
530 limit := 50
531 if limitStr != "" {
532 if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
533 limit = l
534 }
535 }
536
537 query := `SELECT m.id, m.conversationId, m.type, m.body, m.sourceServiceId, m.sent_at,
538 COALESCE(c.name, c.profileName, c.e164, c.id) as conversation_name
539 FROM messages m
540 JOIN conversations c ON m.conversationId = c.id
541 WHERE m.body LIKE '%` + searchTerm + `%'
542 AND m.type NOT IN ('keychange', 'profile-change')
543 AND m.type IS NOT NULL
544 ORDER BY m.sent_at DESC
545 LIMIT ` + strconv.Itoa(limit)
546
547 output, err := signal.executeQuery(query)
548 if err != nil {
549 return mcp.NewToolError(fmt.Sprintf("Search failed: %v", err)), nil
550 }
551
552 var messages []map[string]interface{}
553 if err := json.Unmarshal(output, &messages); err != nil {
554 return mcp.NewToolError(fmt.Sprintf("Failed to parse search results: %v", err)), nil
555 }
556
557 result := fmt.Sprintf("Found %d messages containing '%s':\n\n", len(messages), searchTerm)
558 for _, msg := range messages {
559 conversationName := msg["conversation_name"]
560 if conversationName == nil {
561 conversationName = "Unknown"
562 }
563
564 body := msg["body"]
565 if body == nil {
566 body = ""
567 }
568
569 if sentAt, ok := msg["sent_at"].(float64); ok {
570 t := time.Unix(int64(sentAt/1000), (int64(sentAt)%1000)*1000000)
571 result += fmt.Sprintf("**%s** (%s)\n%s\n---\n",
572 conversationName, t.Format("2006-01-02 15:04"), body)
573 } else {
574 result += fmt.Sprintf("**%s**\n%s\n---\n", conversationName, body)
575 }
576 }
577
578 return mcp.NewToolResult(mcp.NewTextContent(result)), nil
579}
580
581func (signal *SignalOperations) handleGetConversation(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
582 args := req.Arguments
583 conversationID, ok := args["conversation_id"].(string)
584 if !ok || conversationID == "" {
585 return mcp.NewToolError("conversation_id parameter is required"), nil
586 }
587
588 limitStr, _ := args["limit"].(string)
589 limit := 100
590 if limitStr != "" {
591 if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
592 limit = l
593 }
594 }
595
596 query := `SELECT m.id, m.type, m.body, m.sourceServiceId, m.sent_at, m.json
597 FROM messages m
598 WHERE m.conversationId = '` + conversationID + `'
599 AND m.type NOT IN ('keychange', 'profile-change')
600 AND m.type IS NOT NULL
601 ORDER BY m.sent_at DESC
602 LIMIT ` + strconv.Itoa(limit)
603
604 output, err := signal.executeQuery(query)
605 if err != nil {
606 return mcp.NewToolError(fmt.Sprintf("Query failed: %v", err)), nil
607 }
608
609 var messages []map[string]interface{}
610 if err := json.Unmarshal(output, &messages); err != nil {
611 return mcp.NewToolError(fmt.Sprintf("Failed to parse conversation data: %v", err)), nil
612 }
613
614 result := fmt.Sprintf("Conversation %s (%d messages):\n\n", conversationID, len(messages))
615 for _, msg := range messages {
616 attachInfo := ""
617
618 // Check for attachments in JSON data
619 if jsonData, ok := msg["json"].(string); ok && jsonData != "" {
620 var rawJSON map[string]interface{}
621 if err := json.Unmarshal([]byte(jsonData), &rawJSON); err == nil {
622 if attachments, ok := rawJSON["attachments"].([]interface{}); ok && len(attachments) > 0 {
623 attachInfo = fmt.Sprintf(" [%d attachments]", len(attachments))
624 }
625 }
626 }
627
628 body := msg["body"]
629 if body == nil {
630 body = ""
631 }
632
633 if sentAt, ok := msg["sent_at"].(float64); ok {
634 t := time.Unix(int64(sentAt/1000), (int64(sentAt)%1000)*1000000)
635 result += fmt.Sprintf("%s: %s%s\n",
636 t.Format("2006-01-02 15:04"), body, attachInfo)
637 } else {
638 result += fmt.Sprintf("%s%s\n", body, attachInfo)
639 }
640 }
641
642 return mcp.NewToolResult(mcp.NewTextContent(result)), nil
643}
644
645func (signal *SignalOperations) handleGetContact(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
646 args := req.Arguments
647 contactID, ok := args["contact_id"].(string)
648 if !ok || contactID == "" {
649 return mcp.NewToolError("contact_id parameter is required"), nil
650 }
651
652 query := `SELECT id, COALESCE(name, profileName, e164, id) as display_name,
653 profileName, e164, type, active_at
654 FROM conversations
655 WHERE id = '` + contactID + `' OR e164 = '` + contactID + `' OR name = '` + contactID + `' OR profileName = '` + contactID + `'
656 LIMIT 1`
657
658 output, err := signal.executeQuery(query)
659 if err != nil {
660 return mcp.NewToolError(fmt.Sprintf("Query failed: %v", err)), nil
661 }
662
663 var contacts []map[string]interface{}
664 if err := json.Unmarshal(output, &contacts); err != nil {
665 return mcp.NewToolError(fmt.Sprintf("Failed to parse contact data: %v", err)), nil
666 }
667
668 if len(contacts) == 0 {
669 return mcp.NewToolError(fmt.Sprintf("Contact not found: %s", contactID)), nil
670 }
671
672 contact := contacts[0]
673 displayName := contact["display_name"]
674 if displayName == nil {
675 displayName = "Unknown"
676 }
677
678 // Get message count with this contact
679 messageCountQuery := `SELECT COUNT(*) as message_count FROM messages WHERE conversationId = '` + fmt.Sprintf("%v", contact["id"]) + `' AND type NOT IN ('keychange', 'profile-change') AND type IS NOT NULL`
680
681 countOutput, err := signal.executeQuery(messageCountQuery)
682 var messageCount int64 = 0
683 if err == nil {
684 var countResult []map[string]interface{}
685 if err := json.Unmarshal(countOutput, &countResult); err == nil && len(countResult) > 0 {
686 if count, ok := countResult[0]["message_count"].(float64); ok {
687 messageCount = int64(count)
688 }
689 }
690 }
691
692 result := fmt.Sprintf("Contact Details:\n\n")
693 result += fmt.Sprintf("**Name:** %s\n", displayName)
694 if contact["profileName"] != nil && contact["profileName"] != "" {
695 result += fmt.Sprintf("**Profile Name:** %s\n", contact["profileName"])
696 }
697 if contact["e164"] != nil && contact["e164"] != "" {
698 result += fmt.Sprintf("**Phone:** %s\n", contact["e164"])
699 }
700 result += fmt.Sprintf("**Contact ID:** %s\n", contact["id"])
701 if contact["type"] != nil {
702 result += fmt.Sprintf("**Type:** %s\n", contact["type"])
703 }
704 if contact["active_at"] != nil {
705 if timestamp, ok := contact["active_at"].(float64); ok {
706 t := time.Unix(int64(timestamp/1000), 0)
707 result += fmt.Sprintf("**Last Active:** %s\n", t.Format("2006-01-02 15:04:05"))
708 }
709 }
710 result += fmt.Sprintf("**Total Messages:** %d\n", messageCount)
711
712 return mcp.NewToolResult(mcp.NewTextContent(result)), nil
713}
714
715func (signal *SignalOperations) handleGetMessage(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
716 args := req.Arguments
717 messageID, ok := args["message_id"].(string)
718 if !ok || messageID == "" {
719 return mcp.NewToolError("message_id parameter is required"), nil
720 }
721
722 query := `SELECT m.id, m.conversationId, m.type, m.body, m.sourceServiceId, m.sent_at, m.json,
723 COALESCE(c.name, c.profileName, c.e164, c.id) as conversation_name
724 FROM messages m
725 JOIN conversations c ON m.conversationId = c.id
726 WHERE m.id = '` + messageID + `'
727 LIMIT 1`
728
729 output, err := signal.executeQuery(query)
730 if err != nil {
731 return mcp.NewToolError(fmt.Sprintf("Query failed: %v", err)), nil
732 }
733
734 var messages []map[string]interface{}
735 if err := json.Unmarshal(output, &messages); err != nil {
736 return mcp.NewToolError(fmt.Sprintf("Failed to parse message data: %v", err)), nil
737 }
738
739 if len(messages) == 0 {
740 return mcp.NewToolError(fmt.Sprintf("Message not found: %s", messageID)), nil
741 }
742
743 msg := messages[0]
744
745 result := fmt.Sprintf("Message Details:\n\n")
746 result += fmt.Sprintf("**Message ID:** %s\n", msg["id"])
747 result += fmt.Sprintf("**Conversation:** %s\n", msg["conversation_name"])
748 result += fmt.Sprintf("**Type:** %s\n", msg["type"])
749
750 if sentAt, ok := msg["sent_at"].(float64); ok {
751 t := time.Unix(int64(sentAt/1000), (int64(sentAt)%1000)*1000000)
752 result += fmt.Sprintf("**Sent:** %s\n", t.Format("2006-01-02 15:04:05"))
753 }
754
755 if msg["body"] != nil && fmt.Sprintf("%v", msg["body"]) != "" {
756 result += fmt.Sprintf("**Message:** %s\n", msg["body"])
757 }
758
759 // Parse JSON data for additional details
760 if jsonData, ok := msg["json"].(string); ok && jsonData != "" {
761 var rawJSON map[string]interface{}
762 if err := json.Unmarshal([]byte(jsonData), &rawJSON); err == nil {
763 // Check for attachments
764 if attachments, ok := rawJSON["attachments"].([]interface{}); ok && len(attachments) > 0 {
765 result += fmt.Sprintf("**Attachments:** %d\n", len(attachments))
766 for i, att := range attachments {
767 if attMap, ok := att.(map[string]interface{}); ok {
768 if fileName, ok := attMap["fileName"].(string); ok {
769 result += fmt.Sprintf(" %d. %s", i+1, fileName)
770 if contentType, ok := attMap["contentType"].(string); ok {
771 result += fmt.Sprintf(" (%s)", contentType)
772 }
773 if size, ok := attMap["size"].(float64); ok {
774 result += fmt.Sprintf(" - %.1f KB", size/1024)
775 }
776 result += "\n"
777 }
778 }
779 }
780 }
781
782 // Check for reactions
783 if reactions, ok := rawJSON["reactions"].([]interface{}); ok && len(reactions) > 0 {
784 result += "**Reactions:** "
785 var reactionStrs []string
786 for _, reaction := range reactions {
787 if r, ok := reaction.(map[string]interface{}); ok {
788 if emoji, exists := r["emoji"].(string); exists {
789 reactionStrs = append(reactionStrs, emoji)
790 }
791 }
792 }
793 result += strings.Join(reactionStrs, " ") + "\n"
794 }
795
796 // Check for quoted message
797 if quote, ok := rawJSON["quote"].(map[string]interface{}); ok {
798 if quotedText, exists := quote["text"].(string); exists && strings.TrimSpace(quotedText) != "" {
799 result += fmt.Sprintf("**Quoted Message:** %s\n", strings.TrimSpace(quotedText))
800 }
801 }
802
803 // Check for sticker
804 if sticker, ok := rawJSON["sticker"].(map[string]interface{}); ok && len(sticker) > 0 {
805 result += "**Sticker:** Yes\n"
806 }
807 }
808 }
809
810 return mcp.NewToolResult(mcp.NewTextContent(result)), nil
811}
812
813func (signal *SignalOperations) handleListAttachments(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
814 args := req.Arguments
815
816 // Optional conversation filter
817 conversationID, _ := args["conversation_id"].(string)
818
819 // Optional limit
820 limitStr, _ := args["limit"].(string)
821 limit := 20
822 if limitStr != "" {
823 if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
824 limit = l
825 }
826 }
827
828 query := `SELECT m.id as message_id, m.conversationId, m.sent_at, m.json,
829 COALESCE(c.name, c.profileName, c.e164, c.id) as conversation_name
830 FROM messages m
831 JOIN conversations c ON m.conversationId = c.id
832 WHERE m.json IS NOT NULL
833 AND m.json LIKE '%"attachments":%'
834 AND m.type NOT IN ('keychange', 'profile-change')
835 AND m.type IS NOT NULL`
836
837 if conversationID != "" {
838 query += ` AND m.conversationId = '` + conversationID + `'`
839 }
840
841 query += ` ORDER BY m.sent_at DESC LIMIT ` + strconv.Itoa(limit)
842
843 output, err := signal.executeQuery(query)
844 if err != nil {
845 return mcp.NewToolError(fmt.Sprintf("Query failed: %v", err)), nil
846 }
847
848 var messages []map[string]interface{}
849 if err := json.Unmarshal(output, &messages); err != nil {
850 return mcp.NewToolError(fmt.Sprintf("Failed to parse attachment data: %v", err)), nil
851 }
852
853 var attachments []map[string]interface{}
854
855 for _, msg := range messages {
856 if jsonData, ok := msg["json"].(string); ok && jsonData != "" {
857 var rawJSON map[string]interface{}
858 if err := json.Unmarshal([]byte(jsonData), &rawJSON); err == nil {
859 if attachmentList, ok := rawJSON["attachments"].([]interface{}); ok {
860 for _, att := range attachmentList {
861 if attMap, ok := att.(map[string]interface{}); ok {
862 attachment := map[string]interface{}{
863 "message_id": msg["message_id"],
864 "conversation_name": msg["conversation_name"],
865 "conversation_id": msg["conversationId"],
866 "sent_at": msg["sent_at"],
867 }
868
869 // Copy attachment properties
870 if fileName, ok := attMap["fileName"].(string); ok {
871 attachment["file_name"] = fileName
872 }
873 if contentType, ok := attMap["contentType"].(string); ok {
874 attachment["content_type"] = contentType
875 }
876 if size, ok := attMap["size"].(float64); ok {
877 attachment["size"] = size
878 }
879 if path, ok := attMap["path"].(string); ok {
880 attachment["path"] = path
881 }
882
883 attachments = append(attachments, attachment)
884 }
885 }
886 }
887 }
888 }
889 }
890
891 result := fmt.Sprintf("Attachments (%d found):\n\n", len(attachments))
892
893 if len(attachments) == 0 {
894 result += "No attachments found."
895 if conversationID != "" {
896 result += fmt.Sprintf(" (in conversation: %s)", conversationID)
897 }
898 result += "\n"
899 } else {
900 for i, att := range attachments {
901 result += fmt.Sprintf("**%d. %s**\n", i+1, att["file_name"])
902 result += fmt.Sprintf(" Conversation: %s\n", att["conversation_name"])
903
904 if sentAt, ok := att["sent_at"].(float64); ok {
905 t := time.Unix(int64(sentAt/1000), (int64(sentAt)%1000)*1000000)
906 result += fmt.Sprintf(" Sent: %s\n", t.Format("2006-01-02 15:04"))
907 }
908
909 if contentType, ok := att["content_type"].(string); ok {
910 result += fmt.Sprintf(" Type: %s\n", contentType)
911 }
912
913 if size, ok := att["size"].(float64); ok {
914 if size < 1024 {
915 result += fmt.Sprintf(" Size: %.0f bytes\n", size)
916 } else if size < 1024*1024 {
917 result += fmt.Sprintf(" Size: %.1f KB\n", size/1024)
918 } else {
919 result += fmt.Sprintf(" Size: %.1f MB\n", size/(1024*1024))
920 }
921 }
922
923 if messageID, ok := att["message_id"].(string); ok {
924 result += fmt.Sprintf(" Message ID: %s\n", messageID)
925 }
926
927 result += "\n"
928 }
929 }
930
931 return mcp.NewToolResult(mcp.NewTextContent(result)), nil
932}
933
934func (signal *SignalOperations) handleGetStats(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
935 // Get message count using command-line sqlcipher
936 messageQuery := "SELECT COUNT(*) as count FROM messages WHERE type NOT IN ('keychange', 'profile-change') AND type IS NOT NULL"
937 messageOutput, err := signal.executeQuery(messageQuery)
938 if err != nil {
939 return mcp.NewToolError(fmt.Sprintf("Failed to count messages: %v", err)), nil
940 }
941
942 var messageResult []map[string]interface{}
943 totalMessages := 0
944 if err := json.Unmarshal(messageOutput, &messageResult); err == nil && len(messageResult) > 0 {
945 if count, ok := messageResult[0]["count"].(float64); ok {
946 totalMessages = int(count)
947 }
948 }
949
950 // Get conversation count using command-line sqlcipher
951 convQuery := "SELECT COUNT(*) as count FROM conversations WHERE type IS NOT NULL"
952 convOutput, err := signal.executeQuery(convQuery)
953 if err != nil {
954 return mcp.NewToolError(fmt.Sprintf("Failed to count conversations: %v", err)), nil
955 }
956
957 var convResult []map[string]interface{}
958 totalConversations := 0
959 if err := json.Unmarshal(convOutput, &convResult); err == nil && len(convResult) > 0 {
960 if count, ok := convResult[0]["count"].(float64); ok {
961 totalConversations = int(count)
962 }
963 }
964
965 result := fmt.Sprintf("Signal Database Statistics:\n- Total Messages: %d\n- Total Conversations: %d\n- Database Path: %s",
966 totalMessages, totalConversations, signal.dbPath)
967
968 return mcp.NewToolResult(mcp.NewTextContent(result)), nil
969}
970
971func (signal *SignalOperations) handleConversationPrompt(req mcp.GetPromptRequest) (mcp.GetPromptResult, error) {
972 // Get conversation ID from arguments
973 args := req.Arguments
974 conversationID, ok := args["conversation_id"].(string)
975 if !ok || conversationID == "" {
976 return mcp.GetPromptResult{}, fmt.Errorf("conversation_id parameter is required")
977 }
978
979 // Optional analysis type
980 analysisType, _ := args["analysis_type"].(string)
981 if analysisType == "" {
982 analysisType = "general"
983 }
984
985 // Get conversation details
986 convQuery := `SELECT COALESCE(name, profileName, e164, id) as display_name, type, active_at
987 FROM conversations WHERE id = '` + conversationID + `' LIMIT 1`
988
989 convOutput, err := signal.executeQuery(convQuery)
990 if err != nil {
991 return mcp.GetPromptResult{}, fmt.Errorf("failed to get conversation details: %w", err)
992 }
993
994 var conversations []map[string]interface{}
995 if err := json.Unmarshal(convOutput, &conversations); err != nil || len(conversations) == 0 {
996 return mcp.GetPromptResult{}, fmt.Errorf("conversation not found")
997 }
998
999 conv := conversations[0]
1000 displayName := conv["display_name"]
1001 if displayName == nil {
1002 displayName = "Unknown"
1003 }
1004
1005 // Get recent messages from the conversation
1006 msgQuery := `SELECT type, body, sourceServiceId, sent_at, json
1007 FROM messages
1008 WHERE conversationId = '` + conversationID + `'
1009 AND type NOT IN ('keychange', 'profile-change')
1010 AND type IS NOT NULL
1011 ORDER BY sent_at DESC
1012 LIMIT 50`
1013
1014 msgOutput, err := signal.executeQuery(msgQuery)
1015 if err != nil {
1016 return mcp.GetPromptResult{}, fmt.Errorf("failed to get messages: %w", err)
1017 }
1018
1019 var messages []map[string]interface{}
1020 if err := json.Unmarshal(msgOutput, &messages); err != nil {
1021 messages = []map[string]interface{}{}
1022 }
1023
1024 // Build conversation context
1025 var contextBuilder strings.Builder
1026 contextBuilder.WriteString(fmt.Sprintf("Signal Conversation Analysis\n"))
1027 contextBuilder.WriteString(fmt.Sprintf("==========================\n\n"))
1028 contextBuilder.WriteString(fmt.Sprintf("**Conversation:** %s\n", displayName))
1029 contextBuilder.WriteString(fmt.Sprintf("**Analysis Type:** %s\n", analysisType))
1030 contextBuilder.WriteString(fmt.Sprintf("**Total Messages:** %d\n\n", len(messages)))
1031
1032 if len(messages) > 0 {
1033 contextBuilder.WriteString("**Recent Messages:**\n")
1034 for i, msg := range messages {
1035 if i >= 10 { // Limit to 10 most recent for prompt context
1036 break
1037 }
1038
1039 if sentAt, ok := msg["sent_at"].(float64); ok {
1040 t := time.Unix(int64(sentAt/1000), (int64(sentAt)%1000)*1000000)
1041 contextBuilder.WriteString(fmt.Sprintf("- %s: %s\n",
1042 t.Format("2006-01-02 15:04"), msg["body"]))
1043 } else {
1044 contextBuilder.WriteString(fmt.Sprintf("- %s\n", msg["body"]))
1045 }
1046 }
1047 contextBuilder.WriteString("\n")
1048 }
1049
1050 // Build analysis prompt based on type
1051 var prompt string
1052 switch analysisType {
1053 case "sentiment":
1054 prompt = "Analyze the sentiment and emotional tone of this Signal conversation. " +
1055 "Identify patterns in communication style, emotional themes, and relationship dynamics. " +
1056 "Provide insights into the overall mood and any significant emotional shifts."
1057 case "summary":
1058 prompt = "Provide a comprehensive summary of this Signal conversation. " +
1059 "Include key topics discussed, important decisions or agreements made, " +
1060 "and any action items or follow-ups mentioned."
1061 case "patterns":
1062 prompt = "Analyze communication patterns in this Signal conversation. " +
1063 "Look for frequency of messages, response times, common topics, " +
1064 "and any behavioral patterns that emerge from the interaction history."
1065 default:
1066 prompt = "Analyze this Signal conversation and provide insights about the communication. " +
1067 "Include a summary of key topics, relationship dynamics, communication patterns, " +
1068 "and any notable themes or trends in the conversation history."
1069 }
1070
1071 result := mcp.GetPromptResult{
1072 Description: fmt.Sprintf("Analyzing Signal conversation with %s", displayName),
1073 Messages: []mcp.PromptMessage{
1074 {
1075 Role: "user",
1076 Content: mcp.NewTextContent(contextBuilder.String() + "\n" + prompt),
1077 },
1078 },
1079 }
1080
1081 return result, nil
1082}
1083
1084func (signal *SignalOperations) handleSearchPrompt(req mcp.GetPromptRequest) (mcp.GetPromptResult, error) {
1085 // Get search parameters from arguments
1086 args := req.Arguments
1087 searchQuery, ok := args["query"].(string)
1088 if !ok || searchQuery == "" {
1089 return mcp.GetPromptResult{}, fmt.Errorf("query parameter is required")
1090 }
1091
1092 // Optional search scope
1093 searchScope, _ := args["scope"].(string)
1094 if searchScope == "" {
1095 searchScope = "messages"
1096 }
1097
1098 // Optional conversation filter
1099 conversationID, _ := args["conversation_id"].(string)
1100
1101 // Optional time range
1102 timeRange, _ := args["time_range"].(string)
1103 if timeRange == "" {
1104 timeRange = "all"
1105 }
1106
1107 // Build search query based on scope
1108 var searchResults string
1109 var err error
1110
1111 switch searchScope {
1112 case "conversations":
1113 searchResults, err = signal.searchConversations(searchQuery)
1114 case "contacts":
1115 searchResults, err = signal.searchContacts(searchQuery)
1116 default: // "messages" or anything else
1117 searchResults, err = signal.searchMessages(searchQuery, conversationID, timeRange)
1118 }
1119
1120 if err != nil {
1121 return mcp.GetPromptResult{}, fmt.Errorf("search failed: %w", err)
1122 }
1123
1124 // Build context for the search prompt
1125 var contextBuilder strings.Builder
1126 contextBuilder.WriteString(fmt.Sprintf("Signal Search Results\n"))
1127 contextBuilder.WriteString(fmt.Sprintf("=====================\n\n"))
1128 contextBuilder.WriteString(fmt.Sprintf("**Search Query:** %s\n", searchQuery))
1129 contextBuilder.WriteString(fmt.Sprintf("**Search Scope:** %s\n", searchScope))
1130 if conversationID != "" {
1131 contextBuilder.WriteString(fmt.Sprintf("**Conversation Filter:** %s\n", conversationID))
1132 }
1133 contextBuilder.WriteString(fmt.Sprintf("**Time Range:** %s\n\n", timeRange))
1134 contextBuilder.WriteString("**Results:**\n")
1135 contextBuilder.WriteString(searchResults)
1136 contextBuilder.WriteString("\n")
1137
1138 // Build analysis prompt
1139 prompt := "Analyze these Signal search results and provide insights. " +
1140 "Summarize the key findings, identify patterns or themes, " +
1141 "and highlight any important information or trends from the search results. " +
1142 "If there are specific messages or conversations of note, explain their significance."
1143
1144 result := mcp.GetPromptResult{
1145 Description: fmt.Sprintf("Searching Signal for: %s", searchQuery),
1146 Messages: []mcp.PromptMessage{
1147 {
1148 Role: "user",
1149 Content: mcp.NewTextContent(contextBuilder.String() + "\n" + prompt),
1150 },
1151 },
1152 }
1153
1154 return result, nil
1155}
1156
1157func (signal *SignalOperations) searchMessages(query, conversationID, timeRange string) (string, error) {
1158 sqlQuery := `SELECT m.id, m.conversationId, m.body, m.sent_at,
1159 COALESCE(c.name, c.profileName, c.e164, c.id) as conversation_name
1160 FROM messages m
1161 JOIN conversations c ON m.conversationId = c.id
1162 WHERE m.body LIKE '%` + query + `%'
1163 AND m.type NOT IN ('keychange', 'profile-change')
1164 AND m.type IS NOT NULL`
1165
1166 if conversationID != "" {
1167 sqlQuery += ` AND m.conversationId = '` + conversationID + `'`
1168 }
1169
1170 // Add time range filter
1171 switch timeRange {
1172 case "today":
1173 sqlQuery += ` AND m.sent_at > ` + fmt.Sprintf("%d", time.Now().AddDate(0, 0, -1).Unix()*1000)
1174 case "week":
1175 sqlQuery += ` AND m.sent_at > ` + fmt.Sprintf("%d", time.Now().AddDate(0, 0, -7).Unix()*1000)
1176 case "month":
1177 sqlQuery += ` AND m.sent_at > ` + fmt.Sprintf("%d", time.Now().AddDate(0, -1, 0).Unix()*1000)
1178 }
1179
1180 sqlQuery += ` ORDER BY m.sent_at DESC LIMIT 20`
1181
1182 output, err := signal.executeQuery(sqlQuery)
1183 if err != nil {
1184 return "", err
1185 }
1186
1187 var messages []map[string]interface{}
1188 if err := json.Unmarshal(output, &messages); err != nil {
1189 return "No messages found.", nil
1190 }
1191
1192 if len(messages) == 0 {
1193 return "No messages found matching the search criteria.", nil
1194 }
1195
1196 var result strings.Builder
1197 result.WriteString(fmt.Sprintf("Found %d messages:\n\n", len(messages)))
1198
1199 for i, msg := range messages {
1200 if sentAt, ok := msg["sent_at"].(float64); ok {
1201 t := time.Unix(int64(sentAt/1000), (int64(sentAt)%1000)*1000000)
1202 result.WriteString(fmt.Sprintf("%d. **%s** (%s)\n", i+1,
1203 msg["conversation_name"], t.Format("2006-01-02 15:04")))
1204 } else {
1205 result.WriteString(fmt.Sprintf("%d. **%s**\n", i+1, msg["conversation_name"]))
1206 }
1207 result.WriteString(fmt.Sprintf(" %s\n\n", msg["body"]))
1208 }
1209
1210 return result.String(), nil
1211}
1212
1213func (signal *SignalOperations) searchConversations(query string) (string, error) {
1214 sqlQuery := `SELECT id, COALESCE(name, profileName, e164, id) as display_name,
1215 profileName, e164, type, active_at
1216 FROM conversations
1217 WHERE (name LIKE '%` + query + `%' OR profileName LIKE '%` + query + `%' OR e164 LIKE '%` + query + `%')
1218 AND type IS NOT NULL
1219 ORDER BY active_at DESC
1220 LIMIT 10`
1221
1222 output, err := signal.executeQuery(sqlQuery)
1223 if err != nil {
1224 return "", err
1225 }
1226
1227 var conversations []map[string]interface{}
1228 if err := json.Unmarshal(output, &conversations); err != nil {
1229 return "No conversations found.", nil
1230 }
1231
1232 if len(conversations) == 0 {
1233 return "No conversations found matching the search criteria.", nil
1234 }
1235
1236 var result strings.Builder
1237 result.WriteString(fmt.Sprintf("Found %d conversations:\n\n", len(conversations)))
1238
1239 for i, conv := range conversations {
1240 displayName := conv["display_name"]
1241 if displayName == nil {
1242 displayName = "Unknown"
1243 }
1244
1245 result.WriteString(fmt.Sprintf("%d. **%s**\n", i+1, displayName))
1246 if conv["e164"] != nil && conv["e164"] != "" {
1247 result.WriteString(fmt.Sprintf(" Phone: %s\n", conv["e164"]))
1248 }
1249 if conv["type"] != nil {
1250 result.WriteString(fmt.Sprintf(" Type: %s\n", conv["type"]))
1251 }
1252 if activeAt, ok := conv["active_at"].(float64); ok {
1253 t := time.Unix(int64(activeAt/1000), 0)
1254 result.WriteString(fmt.Sprintf(" Last Active: %s\n", t.Format("2006-01-02 15:04")))
1255 }
1256 result.WriteString("\n")
1257 }
1258
1259 return result.String(), nil
1260}
1261
1262func (signal *SignalOperations) searchContacts(query string) (string, error) {
1263 // Same as searchConversations but with different framing
1264 return signal.searchConversations(query)
1265}