diff --git a/README.md b/README.md index 6debb1f..17f3453 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ This feature-rich Slack MCP Server has: - **Enterprise Workspaces Support**: Possibility to integrate with Enterprise Slack setups. - **Channel and Thread Support with `#Name` `@Lookup`**: Fetch messages from channels and threads, including activity messages, and retrieve channels using their names (e.g., #general) as well as their IDs. - **Smart History**: Fetch messages with pagination by date (d1, 7d, 1m) or message count. +- **Unread Messages**: Get all unread messages across channels efficiently with priority sorting (DMs > partner channels > internal), @mention filtering, and mark-as-read support. - **Search Messages**: Search messages in channels, threads, and DMs using various filters like date, user, and content. - **Safe Message Posting**: The `conversations_add_message` tool is disabled by default for safety. Enable it via an environment variable, with optional channel restrictions. - **DM and Group DM support**: Retrieve direct messages and group direct messages. @@ -84,6 +85,21 @@ Get list of channels - `limit` (number, default: 100): The maximum number of items to return. Must be an integer between 1 and 1000 (maximum 999). - `cursor` (string, optional): Cursor for pagination. Use the value of the last row and column in the response as next_cursor field returned from the previous request. +### 6. conversations_unreads +Get unread messages across all channels efficiently. Uses a single API call to identify channels with unreads, then fetches only those messages. Results are prioritized: DMs > partner channels (Slack Connect) > internal channels. +- **Parameters:** + - `include_messages` (boolean, default: true): If true, returns the actual unread messages. If false, returns only a summary of channels with unreads. + - `channel_types` (string, default: "all"): Filter by channel type: `all`, `dm` (direct messages), `group_dm` (group DMs), `partner` (externally shared channels), `internal` (regular workspace channels). + - `max_channels` (number, default: 50): Maximum number of channels to fetch unreads from. + - `max_messages_per_channel` (number, default: 10): Maximum messages to fetch per channel. + - `mentions_only` (boolean, default: false): If true, only returns channels where you have @mentions. + +### 7. conversations_mark +Mark a channel or DM as read. +- **Parameters:** + - `channel_id` (string, required): ID of the channel in format `Cxxxxxxxxxx` or its name starting with `#...` or `@...` (e.g., `#general`, `@username`). + - `ts` (string, optional): Timestamp of the message to mark as read up to. If not provided, marks all messages as read. + ## Resources The Slack MCP Server exposes two special directory resources for easy access to workspace metadata: diff --git a/build/slack-mcp-server b/build/slack-mcp-server new file mode 100755 index 0000000..c816a68 Binary files /dev/null and b/build/slack-mcp-server differ diff --git a/pkg/handler/conversations.go b/pkg/handler/conversations.go index 3a06387..e73123f 100644 --- a/pkg/handler/conversations.go +++ b/pkg/handler/conversations.go @@ -8,6 +8,7 @@ import ( "net/url" "os" "regexp" + "sort" "strconv" "strings" "time" @@ -341,6 +342,335 @@ func (ch *ConversationsHandler) ConversationsSearchHandler(ctx context.Context, return marshalMessagesToCSV(messages) } +// UnreadChannel represents a channel with unread messages +type UnreadChannel struct { + ChannelID string `json:"channelID"` + ChannelName string `json:"channelName"` + ChannelType string `json:"channelType"` // "dm", "group_dm", "partner", "internal" + UnreadCount int `json:"unreadCount"` + LastRead string `json:"lastRead"` + Latest string `json:"latest"` +} + +// UnreadMessage extends Message with channel context +type UnreadMessage struct { + Message + ChannelType string `json:"channelType"` +} + +// ConversationsUnreadsHandler returns unread messages across all channels +func (ch *ConversationsHandler) ConversationsUnreadsHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ch.logger.Debug("ConversationsUnreadsHandler called", zap.Any("params", request.Params)) + + // Get optional parameters + includeMessages := request.GetBool("include_messages", true) + channelTypes := request.GetString("channel_types", "all") // "all", "dm", "group_dm", "partner", "internal" + maxChannels := request.GetInt("max_channels", 50) + maxMessagesPerChannel := request.GetInt("max_messages_per_channel", 10) + mentionsOnly := request.GetBool("mentions_only", false) // Priority Inbox: only show channels with @mentions + + // Call ClientCounts to get unread status for all channels efficiently + // This uses the undocumented client.counts API which returns HasUnreads for all channels + counts, err := ch.apiProvider.Slack().ClientCounts(ctx) + if err != nil { + ch.logger.Error("ClientCounts failed", zap.Error(err)) + return nil, fmt.Errorf("failed to get client counts: %v", err) + } + + ch.logger.Debug("Got counts data", + zap.Int("channels", len(counts.Channels)), + zap.Int("mpims", len(counts.MPIMs)), + zap.Int("ims", len(counts.IMs))) + + // Get users map and channels map for resolving names + usersMap := ch.apiProvider.ProvideUsersMap() + channelsMaps := ch.apiProvider.ProvideChannelsMaps() + + // Collect channels with unreads + var unreadChannels []UnreadChannel + + // Process regular channels (public, private) + for _, snap := range counts.Channels { + if !snap.HasUnreads { + continue + } + + // Priority Inbox: skip channels without @mentions + if mentionsOnly && snap.MentionCount == 0 { + continue + } + + // Get channel info from cache to determine type and name + channelName := snap.ID + channelType := "internal" + if cached, ok := channelsMaps.Channels[snap.ID]; ok { + // The cached name may already have # prefix, so handle both cases + name := cached.Name + if strings.HasPrefix(name, "#") { + channelName = name + } else { + channelName = "#" + name + } + // Check if it's a partner/external channel using Slack's metadata + if cached.IsExtShared { + channelType = "partner" + } + } + + // Filter by requested channel types + if channelTypes != "all" && channelType != channelTypes { + continue + } + + unreadChannels = append(unreadChannels, UnreadChannel{ + ChannelID: snap.ID, + ChannelName: channelName, + ChannelType: channelType, + UnreadCount: snap.MentionCount, + LastRead: snap.LastRead.SlackString(), + Latest: snap.Latest.SlackString(), + }) + } + + // Process MPIMs (group DMs) + for _, snap := range counts.MPIMs { + if !snap.HasUnreads { + continue + } + + // Priority Inbox: skip channels without @mentions + if mentionsOnly && snap.MentionCount == 0 { + continue + } + + // Filter by requested channel types + if channelTypes != "all" && channelTypes != "group_dm" { + continue + } + + channelName := snap.ID + if cached, ok := channelsMaps.Channels[snap.ID]; ok { + channelName = cached.Name + } + + unreadChannels = append(unreadChannels, UnreadChannel{ + ChannelID: snap.ID, + ChannelName: channelName, + ChannelType: "group_dm", + UnreadCount: snap.MentionCount, + LastRead: snap.LastRead.SlackString(), + Latest: snap.Latest.SlackString(), + }) + } + + // Process IMs (direct messages) + for _, snap := range counts.IMs { + if !snap.HasUnreads { + continue + } + + // Priority Inbox: skip channels without @mentions + if mentionsOnly && snap.MentionCount == 0 { + continue + } + + // Filter by requested channel types + if channelTypes != "all" && channelTypes != "dm" { + continue + } + + // Get display name for DM from channel cache or users + channelName := snap.ID + if cached, ok := channelsMaps.Channels[snap.ID]; ok { + if cached.User != "" { + if u, ok := usersMap.Users[cached.User]; ok { + channelName = "@" + u.Name + } else { + channelName = "@" + cached.User + } + } + } + + unreadChannels = append(unreadChannels, UnreadChannel{ + ChannelID: snap.ID, + ChannelName: channelName, + ChannelType: "dm", + UnreadCount: snap.MentionCount, + LastRead: snap.LastRead.SlackString(), + Latest: snap.Latest.SlackString(), + }) + } + + // Sort by priority: DMs > partner channels > internal + ch.sortChannelsByPriority(unreadChannels) + + // Limit channels + if len(unreadChannels) > maxChannels { + unreadChannels = unreadChannels[:maxChannels] + } + + ch.logger.Debug("Found unread channels", zap.Int("count", len(unreadChannels))) + + // If not including messages, just return channel summary + if !includeMessages { + return ch.marshalUnreadChannelsToCSV(unreadChannels) + } + + // Fetch messages for each unread channel + var allMessages []Message + + for _, uc := range unreadChannels { + historyParams := slack.GetConversationHistoryParameters{ + ChannelID: uc.ChannelID, + Oldest: uc.LastRead, + Limit: maxMessagesPerChannel, + Inclusive: false, + } + + history, err := ch.apiProvider.Slack().GetConversationHistoryContext(ctx, &historyParams) + if err != nil { + ch.logger.Warn("Failed to get history for channel", + zap.String("channel", uc.ChannelID), + zap.Error(err)) + continue + } + + // Update unread count + uc.UnreadCount = len(history.Messages) + + // Convert messages + channelMessages := ch.convertMessagesFromHistory(history.Messages, uc.ChannelName, false) + allMessages = append(allMessages, channelMessages...) + } + + ch.logger.Debug("Fetched unread messages", zap.Int("total", len(allMessages))) + + return marshalMessagesToCSV(allMessages) +} + +// ConversationsMarkHandler marks a channel as read up to a specific timestamp +func (ch *ConversationsHandler) ConversationsMarkHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ch.logger.Debug("ConversationsMarkHandler called", zap.Any("params", request.Params)) + + channel := request.GetString("channel_id", "") + if channel == "" { + return nil, errors.New("channel_id is required") + } + + // Resolve channel name to ID if needed + if strings.HasPrefix(channel, "#") || strings.HasPrefix(channel, "@") { + channelsMaps := ch.apiProvider.ProvideChannelsMaps() + chn, ok := channelsMaps.ChannelsInv[channel] + if !ok { + ch.logger.Error("Channel not found", zap.String("channel", channel)) + return nil, fmt.Errorf("channel %q not found", channel) + } + channel = channelsMaps.Channels[chn].ID + } + + // Get timestamp - if not provided, mark all as read by getting latest message timestamp + ts := request.GetString("ts", "") + if ts == "" { + // Fetch the latest message to get its timestamp + historyParams := slack.GetConversationHistoryParameters{ + ChannelID: channel, + Limit: 1, + } + history, err := ch.apiProvider.Slack().GetConversationHistoryContext(ctx, &historyParams) + if err != nil { + ch.logger.Error("Failed to get latest message", zap.Error(err)) + return nil, fmt.Errorf("failed to get latest message: %v", err) + } + if len(history.Messages) > 0 { + ts = history.Messages[0].Timestamp + } else { + // No messages in channel, nothing to mark + return mcp.NewToolResultText("No messages to mark as read"), nil + } + } + + // Mark the conversation as read + err := ch.apiProvider.Slack().MarkConversationContext(ctx, channel, ts) + if err != nil { + ch.logger.Error("Failed to mark conversation", zap.Error(err)) + return nil, fmt.Errorf("failed to mark conversation as read: %v", err) + } + + ch.logger.Info("Marked conversation as read", + zap.String("channel", channel), + zap.String("ts", ts)) + + return mcp.NewToolResultText(fmt.Sprintf("Marked %s as read up to %s", channel, ts)), nil +} + +// categorizeChannel determines the type of channel for prioritization +func (ch *ConversationsHandler) categorizeChannel(id, name string, isIM, isMpIM, isPrivate, isExtShared bool) string { + if isIM { + return "dm" + } + if isMpIM { + return "group_dm" + } + // Check if it's a partner/external channel using Slack's metadata + if isExtShared { + return "partner" + } + return "internal" +} + +// getChannelDisplayName returns a human-readable channel name +func (ch *ConversationsHandler) getChannelDisplayName(id, name string, isIM, isMpIM bool, members []string, usersMap map[string]slack.User, channelsMaps *provider.ChannelsCache) string { + // Try to get from cache first + if cached, ok := channelsMaps.Channels[id]; ok { + return cached.Name + } + + if isIM && len(members) > 0 { + // For DMs, show the other user's name + for _, memberID := range members { + if u, ok := usersMap[memberID]; ok { + return "@" + u.Name + } + } + return "@" + id + } + + if isMpIM { + return "@" + name + } + + if name != "" { + return "#" + name + } + + return id +} + +// sortChannelsByPriority sorts channels: DMs > group_dm > partner > internal +func (ch *ConversationsHandler) sortChannelsByPriority(channels []UnreadChannel) { + priority := map[string]int{ + "dm": 0, + "group_dm": 1, + "partner": 2, + "internal": 3, + } + + sort.Slice(channels, func(i, j int) bool { + pi := priority[channels[i].ChannelType] + pj := priority[channels[j].ChannelType] + return pi < pj + }) +} + +// marshalUnreadChannelsToCSV converts unread channels to CSV format +func (ch *ConversationsHandler) marshalUnreadChannelsToCSV(channels []UnreadChannel) (*mcp.CallToolResult, error) { + csvBytes, err := gocsv.MarshalBytes(&channels) + if err != nil { + return nil, err + } + return mcp.NewToolResultText(string(csvBytes)), nil +} + func isChannelAllowed(channel string) bool { config := os.Getenv("SLACK_MCP_ADD_MESSAGE_TOOL") if config == "" || config == "true" || config == "1" { diff --git a/pkg/provider/api.go b/pkg/provider/api.go index f67f1b9..3aa1826 100644 --- a/pkg/provider/api.go +++ b/pkg/provider/api.go @@ -64,6 +64,7 @@ type Channel struct { IsMpIM bool `json:"mpim"` IsIM bool `json:"im"` IsPrivate bool `json:"private"` + IsExtShared bool `json:"is_ext_shared"` // Shared with external organizations User string `json:"user,omitempty"` // User ID for IM channels Members []string `json:"members,omitempty"` // Member IDs for the channel } @@ -87,6 +88,7 @@ type SlackAPI interface { // Edge API methods ClientUserBoot(ctx context.Context) (*edge.ClientUserBootResponse, error) + ClientCounts(ctx context.Context) (edge.ClientCountsResponse, error) } type MCPSlackClient struct { @@ -288,6 +290,10 @@ func (c *MCPSlackClient) ClientUserBoot(ctx context.Context) (*edge.ClientUserBo return c.edgeClient.ClientUserBoot(ctx) } +func (c *MCPSlackClient) ClientCounts(ctx context.Context) (edge.ClientCountsResponse, error) { + return c.edgeClient.ClientCounts(ctx) +} + func (c *MCPSlackClient) IsEnterprise() bool { return c.isEnterprise } @@ -559,7 +565,7 @@ func (ap *ApiProvider) RefreshChannels(ctx context.Context) error { remappedChannel := mapChannel( c.ID, "", "", c.Topic, c.Purpose, c.User, c.Members, c.MemberCount, - c.IsIM, c.IsMpIM, c.IsPrivate, + c.IsIM, c.IsMpIM, c.IsPrivate, c.IsExtShared, usersMap, ) ap.channels[c.ID] = remappedChannel @@ -677,6 +683,7 @@ func (ap *ApiProvider) GetChannelsType(ctx context.Context, channelType string) channel.IsIM, channel.IsMpIM, channel.IsPrivate, + channel.IsExtShared, ap.ProvideUsersMap().Users, ) chans = append(chans, ch) @@ -769,7 +776,7 @@ func mapChannel( id, name, nameNormalized, topic, purpose, user string, members []string, numMembers int, - isIM, isMpIM, isPrivate bool, + isIM, isMpIM, isPrivate, isExtShared bool, usersMap map[string]slack.User, ) Channel { channelName := name @@ -833,6 +840,7 @@ func mapChannel( IsIM: isIM, IsMpIM: isMpIM, IsPrivate: isPrivate, + IsExtShared: isExtShared, User: userID, Members: members, } diff --git a/pkg/server/server.go b/pkg/server/server.go index 0260152..5f6eb0a 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -147,6 +147,47 @@ func NewMCPServer(provider *provider.ApiProvider, logger *zap.Logger) *MCPServer s.AddTool(conversationsSearchTool, conversationsHandler.ConversationsSearchHandler) } + // Register unreads tool - gets all unread messages across channels efficiently + s.AddTool(mcp.NewTool("conversations_unreads", + mcp.WithDescription("Get unread messages across all channels. Uses a single API call to identify channels with unreads, then fetches only those messages. Results are prioritized: DMs > partner channels (ext-*) > internal channels."), + mcp.WithTitleAnnotation("Get Unread Messages"), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithBoolean("include_messages", + mcp.Description("If true (default), returns the actual unread messages. If false, returns only a summary of channels with unreads."), + mcp.DefaultBool(true), + ), + mcp.WithString("channel_types", + mcp.Description("Filter by channel type: 'all' (default), 'dm' (direct messages), 'group_dm' (group DMs), 'partner' (ext-* channels), 'internal' (other channels)."), + mcp.DefaultString("all"), + ), + mcp.WithNumber("max_channels", + mcp.Description("Maximum number of channels to fetch unreads from. Default is 50."), + mcp.DefaultNumber(50), + ), + mcp.WithNumber("max_messages_per_channel", + mcp.Description("Maximum messages to fetch per channel. Default is 10."), + mcp.DefaultNumber(10), + ), + mcp.WithBoolean("mentions_only", + mcp.Description("If true, only returns channels where you have @mentions. Default is false."), + mcp.DefaultBool(false), + ), + ), conversationsHandler.ConversationsUnreadsHandler) + + // Register mark tool - marks a channel as read + s.AddTool(mcp.NewTool("conversations_mark", + mcp.WithDescription("Mark a channel or DM as read. If no timestamp is provided, marks all messages as read."), + mcp.WithTitleAnnotation("Mark as Read"), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithString("channel_id", + mcp.Required(), + mcp.Description("ID of the channel in format Cxxxxxxxxxx or its name starting with #... or @... (e.g., #general, @username)."), + ), + mcp.WithString("ts", + mcp.Description("Timestamp of the message to mark as read up to. If not provided, marks all messages as read."), + ), + ), conversationsHandler.ConversationsMarkHandler) + channelsHandler := handler.NewChannelsHandler(provider, logger) s.AddTool(mcp.NewTool("channels_list",