package tools import ( "context" "fmt" "strconv" "strings" "time" "github.com/modelcontextprotocol/go-sdk/mcp" "isphere-ai-bridge/internal/isphere" "isphere-ai-bridge/internal/msglib" ) const ToolNameReceiveMessages = "isphere_receive_messages" type ReceiveMessagesSource interface { ReceiveMessages(ctx context.Context, query isphere.ReceiveMessagesQuery) (isphere.ReceiveMessagesResult, error) } type ReceiveMessagesArgs struct { ConversationID string `json:"conversation_id,omitempty" jsonschema:"exact conversation id filter for local readonly message logs"` Query string `json:"query,omitempty" jsonschema:"case-insensitive keyword filter over message text, subject, ids, and participants"` Since string `json:"since,omitempty" jsonschema:"RFC3339 timestamp; return messages at or after this time when packet timestamps are available"` Cursor string `json:"cursor,omitempty" jsonschema:"pagination cursor; currently only empty cursor is supported"` SourcePreference string `json:"source_preference,omitempty" jsonschema:"source selector; currently supports auto or local_readonly only"` Preview bool `json:"preview,omitempty" jsonschema:"accepted for contract compatibility; read-only execution remains read mode"` IncludeAttachmentMetadata *bool `json:"include_attachment_metadata,omitempty" jsonschema:"include inline attachment metadata in receive message results; default true"` Limit int `json:"limit,omitempty" jsonschema:"maximum number of messages to return; zero or negative returns all available messages"` } func RegisterISphereReadTools(server *mcp.Server, source ReceiveMessagesSource) { RegisterISphereReadToolsWithDisplayEntities(server, source, nil) } func RegisterISphereReadToolsWithDisplayEntities(server *mcp.Server, source ReceiveMessagesSource, displaySource DisplayEntitySource) { if source == nil { source = isphere.EncryptedPacketLogSource{} } mcp.AddTool[ReceiveMessagesArgs, map[string]any](server, &mcp.Tool{ Name: ToolNameReceiveMessages, Description: "Return iSphere messages from the configured read-only log-backed source.", }, func(ctx context.Context, _ *mcp.CallToolRequest, input ReceiveMessagesArgs) (*mcp.CallToolResult, map[string]any, error) { if err := validateReceiveMessagesArgs(input); err != nil { return nil, nil, err } started := time.Now().UTC() result, err := source.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{ ConversationID: input.ConversationID, Query: input.Query, Since: input.Since, Limit: input.Limit, }) if err != nil { return nil, nil, err } displayNames := receiveMessageDisplayNames{} if displaySource != nil && len(result.Messages) > 0 { loaded, err := loadReceiveMessageDisplayNames(ctx, displaySource) if err != nil { return nil, nil, err } displayNames = loaded } return nil, receiveMessagesResultToMap(result, started, time.Now().UTC(), receiveMessagesMapOptions{ IncludeAttachmentMetadata: includeReceiveMessageAttachmentMetadata(input), DisplayNames: displayNames, }), nil }) } func validateReceiveMessagesArgs(input ReceiveMessagesArgs) error { return validateLocalReadonlySourceAndCursor(ToolNameReceiveMessages, input.SourcePreference, input.Cursor) } func validateLocalReadonlySourceAndCursor(toolName string, sourcePreferenceValue string, cursorValue string) error { sourcePreference := strings.ToLower(strings.TrimSpace(sourcePreferenceValue)) switch sourcePreference { case "", "auto", "local_readonly": default: return fmt.Errorf("%s source_preference %q is not available; only auto and local_readonly are supported", toolName, sourcePreferenceValue) } if strings.TrimSpace(cursorValue) != "" { return fmt.Errorf("%s cursor pagination is not available yet", toolName) } return nil } func includeReceiveMessageAttachmentMetadata(input ReceiveMessagesArgs) bool { if input.IncludeAttachmentMetadata == nil { return true } return *input.IncludeAttachmentMetadata } type receiveMessagesMapOptions struct { IncludeAttachmentMetadata bool DisplayNames receiveMessageDisplayNames } type receiveMessageDisplayNames struct { Contacts map[string]string Groups map[string]string } func receiveMessagesResultToMap(result isphere.ReceiveMessagesResult, started time.Time, finished time.Time, options receiveMessagesMapOptions) map[string]any { messages := make([]map[string]any, 0, len(result.Messages)) for _, message := range result.Messages { attachments := []map[string]any{} if options.IncludeAttachmentMetadata { attachments = receiveMessageAttachments(message) } senderName := nullableDisplayName(options.DisplayNames.Contacts, message.SenderID) messages = append(messages, map[string]any{ "id": message.ID, "message_id": message.ID, "conversation_id": message.ConversationID, "conversation_type": message.ConversationType, "sender_id": message.SenderID, "sender_name": senderName, "receiver_id": message.ReceiverID, "text": message.Text, "content_type": receiveMessageContentType(message, attachments), "content_text": message.Text, "attachments": attachments, "timestamp": message.Timestamp, "created_at": packetTimestampToRFC3339(message.Timestamp), "received_at": nil, "subject": message.Subject, "receipt_id": message.ReceiptID, "read": message.Read, "source": "local_readonly", "raw_ref": "packetlog_message", }) } return map[string]any{ "ok": true, "conversation": receiveMessagesConversation(result.Messages, options.DisplayNames), "messages": messages, "next_cursor": nil, "audit": map[string]any{ "tool": ToolNameReceiveMessages, "source": "local_readonly", "execution_mode": "read", "started_at": started.Format(time.RFC3339Nano), "finished_at": finished.Format(time.RFC3339Nano), "result": "ok", }, } } func receiveMessagesConversation(messages []isphere.Message, displayNames receiveMessageDisplayNames) any { if len(messages) == 0 { return nil } first := messages[0] conversationType := contractConversationType(first.ConversationType) displayName := first.ConversationID if conversationType == "group" { if name := lookupDisplayName(displayNames.Groups, first.SenderID, first.ReceiverID, first.ConversationID); name != "" { displayName = name } } else if name := lookupDisplayName(displayNames.Contacts, first.SenderID, first.ReceiverID); name != "" { displayName = name } return map[string]any{ "conversation_id": first.ConversationID, "conversation_type": conversationType, "display_name": displayName, } } func loadReceiveMessageDisplayNames(ctx context.Context, source DisplayEntitySource) (receiveMessageDisplayNames, error) { contacts, err := source.DisplayEntities(ctx, msglib.DisplayEntitiesOptions{EntityType: msglib.EntityTypeContacts, Limit: msglib.MaxDisplayEntityLimit}) if err != nil { return receiveMessageDisplayNames{}, err } groups, err := source.DisplayEntities(ctx, msglib.DisplayEntitiesOptions{EntityType: msglib.EntityTypeGroups, Limit: msglib.MaxDisplayEntityLimit}) if err != nil { return receiveMessageDisplayNames{}, err } return receiveMessageDisplayNames{ Contacts: displayEntityNameMap(contacts, msglib.EntityTypeContacts), Groups: displayEntityNameMap(groups, msglib.EntityTypeGroups), }, nil } func displayEntityNameMap(entities []msglib.DisplayEntity, entityType string) map[string]string { names := map[string]string{} for _, entity := range entities { if entity.EntityType != "" && entity.EntityType != entityType { continue } jid := strings.ToLower(strings.TrimSpace(entity.JID)) name := strings.TrimSpace(entity.DisplayName) if jid == "" || name == "" { continue } if _, exists := names[jid]; !exists { names[jid] = name } } return names } func nullableDisplayName(names map[string]string, id string) any { if name := lookupDisplayName(names, id); name != "" { return name } return nil } func lookupDisplayName(names map[string]string, candidates ...string) string { if len(names) == 0 { return "" } for _, candidate := range candidates { for _, part := range strings.Split(candidate, "|") { key := strings.ToLower(strings.TrimSpace(part)) if key == "" { continue } if name := names[key]; name != "" { return name } } } return "" } func contractConversationType(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "groupchat", "group": return "group" case "chat", "direct": return "direct" case "system": return "system" default: return "unknown" } } func receiveMessageAttachments(message isphere.Message) []map[string]any { files := isphere.ListFilesFromMessages([]isphere.Message{message}, isphere.ListFilesQuery{}) attachments := make([]map[string]any, 0, len(files.Files)) for _, file := range files.Files { attachments = append(attachments, map[string]any{ "file_id": file.FileID, "file_name": file.FileName, "size_bytes": file.SizeBytes, "download_ref": nullableString(file.DownloadRef), }) } return attachments } func receiveMessageContentType(message isphere.Message, attachments []map[string]any) string { if len(attachments) > 0 { return "file" } if strings.TrimSpace(message.Text) != "" { return "text" } if strings.TrimSpace(message.Subject) != "" { return "notify" } return "unknown" } func packetTimestampToRFC3339(value string) any { trimmed := strings.TrimSpace(value) if trimmed == "" { return nil } epoch, err := strconv.ParseInt(trimmed, 10, 64) if err != nil { return nil } var ts time.Time if epoch > 1_000_000_000_000 { ts = time.UnixMilli(epoch) } else { ts = time.Unix(epoch, 0) } formatted := ts.UTC().Format(time.RFC3339) return formatted }