diff --git a/README.md b/README.md index b9a45c11..5f2c8782 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. @@ -122,6 +123,23 @@ Search for users by name, email, or display name. Returns user details and DM ch - `Title`: User's job title - `DMChannelID`: DM channel ID if available in cache (for quick messaging) +### 9. 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. + +### 10. conversations_mark +Mark a channel or DM as read. + +> **Note:** Marking messages as read is disabled by default for safety. To enable, set the `SLACK_MCP_MARK_TOOL` environment variable to `true` or `1`. See the Environment Variables section below for details. + +- **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: @@ -176,6 +194,7 @@ Fetches a CSV directory of all users in the workspace. | `SLACK_MCP_ADD_MESSAGE_TOOL` | No | `nil` | Enable message posting via `conversations_add_message` by setting it to `true` for all channels, a comma-separated list of channel IDs to whitelist specific channels, or use `!` before a channel ID to allow all except specified ones. If empty, the tool is only registered when explicitly listed in `SLACK_MCP_ENABLED_TOOLS`. | | `SLACK_MCP_ADD_MESSAGE_MARK` | No | `nil` | When `conversations_add_message` is enabled (via `SLACK_MCP_ADD_MESSAGE_TOOL` or `SLACK_MCP_ENABLED_TOOLS`), setting this to `true` will automatically mark sent messages as read. | | `SLACK_MCP_ADD_MESSAGE_UNFURLING` | No | `nil` | Enable to let Slack unfurl posted links or set comma-separated list of domains e.g. `github.com,slack.com` to whitelist unfurling only for them. If text contains whitelisted and unknown domain unfurling will be disabled for security reasons. | +| `SLACK_MCP_MARK_TOOL` | No | `nil` | Enable the `conversations_mark` tool by setting to `true` or `1`. Disabled by default to prevent accidental marking of messages as read. | | `SLACK_MCP_USERS_CACHE` | No | `~/Library/Caches/slack-mcp-server/users_cache.json` (macOS)
`~/.cache/slack-mcp-server/users_cache.json` (Linux)
`%LocalAppData%/slack-mcp-server/users_cache.json` (Windows) | Path to the users cache file. Used to cache Slack user information to avoid repeated API calls on startup. | | `SLACK_MCP_CHANNELS_CACHE` | No | `~/Library/Caches/slack-mcp-server/channels_cache_v2.json` (macOS)
`~/.cache/slack-mcp-server/channels_cache_v2.json` (Linux)
`%LocalAppData%/slack-mcp-server/channels_cache_v2.json` (Windows) | Path to the channels cache file. Used to cache Slack channel information to avoid repeated API calls on startup. | | `SLACK_MCP_LOG_LEVEL` | No | `info` | Log-level for stdout or stderr. Valid values are: `debug`, `info`, `warn`, `error`, `panic` and `fatal` | diff --git a/build/slack-mcp-server b/build/slack-mcp-server new file mode 100755 index 00000000..c816a686 Binary files /dev/null and b/build/slack-mcp-server differ diff --git a/pkg/handler/conversations.go b/pkg/handler/conversations.go index 65395ade..dfac90f2 100644 --- a/pkg/handler/conversations.go +++ b/pkg/handler/conversations.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "regexp" + "sort" "strconv" "strings" "time" @@ -110,6 +111,18 @@ type usersSearchParams struct { limit int } +type unreadsParams struct { + includeMessages bool + channelTypes string + maxChannels int + maxMessagesPerChannel int + mentionsOnly bool +} + +type markParams struct { + channel string + ts string +} type ConversationsHandler struct { apiProvider *provider.ApiProvider logger *zap.Logger @@ -599,6 +612,423 @@ 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)) + + params := ch.parseParamsToolUnreads(request) + + // 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))) + + // Check if ClientCounts returned empty results (happens with xoxp/xoxb tokens) + // If so, fall back to using conversations.info per-channel approach + totalCounts := len(counts.Channels) + len(counts.MPIMs) + len(counts.IMs) + if totalCounts == 0 { + ch.logger.Info("ClientCounts returned empty results, falling back to xoxp-compatible approach using conversations.info") + return ch.getUnreadsUsingConversationsInfo(ctx, params) + } + + // 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 params.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 params.channelTypes != "all" && channelType != params.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 params.mentionsOnly && snap.MentionCount == 0 { + continue + } + + // Filter by requested channel types + if params.channelTypes != "all" && params.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 params.mentionsOnly && snap.MentionCount == 0 { + continue + } + + // Filter by requested channel types + if params.channelTypes != "all" && params.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) > params.maxChannels { + unreadChannels = unreadChannels[:params.maxChannels] + } + + ch.logger.Debug("Found unread channels", zap.Int("count", len(unreadChannels))) + + // If not including messages, just return channel summary + if !params.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: params.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) +} + +// getUnreadsUsingConversationsInfo is a fallback for xoxp/xoxb tokens +// It iterates through all channels and uses conversations.info to check unread_count +func (ch *ConversationsHandler) getUnreadsUsingConversationsInfo(ctx context.Context, params *unreadsParams) (*mcp.CallToolResult, error) { + ch.logger.Debug("Using conversations.info fallback for unread detection") + + // Get users map and channels map for resolving names + usersMap := ch.apiProvider.ProvideUsersMap() + channelsMaps := ch.apiProvider.ProvideChannelsMaps() + + var unreadChannels []UnreadChannel + + // Iterate through all cached channels + for channelID, channel := range channelsMaps.Channels { + // Determine channel type + var channelType string + var channelName string + + if channel.IsIM { + channelType = "dm" + // Get display name for DM + if channel.User != "" { + if u, ok := usersMap.Users[channel.User]; ok { + channelName = "@" + u.Name + } else { + channelName = "@" + channel.User + } + } else { + channelName = channelID + } + } else if channel.IsMpIM { + channelType = "group_dm" + channelName = channel.Name + } else if channel.IsExtShared { + channelType = "partner" + name := channel.Name + if strings.HasPrefix(name, "#") { + channelName = name + } else { + channelName = "#" + name + } + } else { + channelType = "internal" + name := channel.Name + if strings.HasPrefix(name, "#") { + channelName = name + } else { + channelName = "#" + name + } + } + + // Filter by requested channel types + if params.channelTypes != "all" && channelType != params.channelTypes { + continue + } + + // Call conversations.info to get unread count + convInfo, err := ch.apiProvider.Slack().GetConversationInfoContext(ctx, &slack.GetConversationInfoInput{ + ChannelID: channelID, + }) + if err != nil { + ch.logger.Warn("Failed to get conversation info", + zap.String("channel", channelID), + zap.Error(err)) + continue + } + + // Check if channel has unreads + // We only include channels with actual unread messages + if convInfo.UnreadCount == 0 { + continue + } + + // Priority Inbox: skip channels without @mentions if requested + // Note: With xoxp tokens, we don't have access to mention counts, + // so we can't filter by mentions_only. Log a warning and skip this filter. + if params.mentionsOnly { + ch.logger.Warn("mentions_only filter is not supported with xoxp/xoxb tokens, ignoring filter") + } + + unreadChannels = append(unreadChannels, UnreadChannel{ + ChannelID: channelID, + ChannelName: channelName, + ChannelType: channelType, + UnreadCount: convInfo.UnreadCount, + LastRead: "", // Not available via conversations.info + Latest: "", // Not available via conversations.info + }) + + // Check if we've reached max channels limit + if len(unreadChannels) >= params.maxChannels { + break + } + } + + // Sort by priority: DMs > group_dm > partner > internal + ch.sortChannelsByPriority(unreadChannels) + + // Limit channels (in case we exceeded during iteration) + if len(unreadChannels) > params.maxChannels { + unreadChannels = unreadChannels[:params.maxChannels] + } + + ch.logger.Debug("Found unread channels using conversations.info", zap.Int("count", len(unreadChannels))) + + // If not including messages, just return channel summary + if !params.includeMessages { + return ch.marshalUnreadChannelsToCSV(unreadChannels) + } + + // Fetch messages for each unread channel + var allMessages []Message + + for _, uc := range unreadChannels { + // Without last_read timestamp, we fetch recent messages up to the limit + historyParams := slack.GetConversationHistoryParameters{ + ChannelID: uc.ChannelID, + Limit: params.maxMessagesPerChannel, + } + + 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 + } + + // 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)) + + params, err := ch.parseParamsToolMark(request) + if err != nil { + ch.logger.Error("Failed to parse mark params", zap.Error(err)) + return nil, err + } + + channel := params.channel + ts := params.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 +} + +// 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 isChannelAllowedForConfig(channel, config string) bool { if config == "" || config == "true" || config == "1" { return true @@ -1024,6 +1454,58 @@ func (ch *ConversationsHandler) parseParamsToolUsersSearch(request mcp.CallToolR }, nil } +func (ch *ConversationsHandler) parseParamsToolUnreads(request mcp.CallToolRequest) *unreadsParams { + return &unreadsParams{ + includeMessages: request.GetBool("include_messages", true), + channelTypes: request.GetString("channel_types", "all"), + maxChannels: request.GetInt("max_channels", 50), + maxMessagesPerChannel: request.GetInt("max_messages_per_channel", 10), + mentionsOnly: request.GetBool("mentions_only", false), + } +} + +func (ch *ConversationsHandler) parseParamsToolMark(request mcp.CallToolRequest) (*markParams, error) { + toolConfig := os.Getenv("SLACK_MCP_MARK_TOOL") + if toolConfig == "" { + ch.logger.Error("Mark tool disabled by default") + return nil, errors.New( + "by default, the conversations_mark tool is disabled to prevent accidental marking of messages as read. " + + "To enable it, set the SLACK_MCP_MARK_TOOL environment variable to true or 1, " + + "e.g. 'SLACK_MCP_MARK_TOOL=true'", + ) + } + if toolConfig != "1" && toolConfig != "true" && toolConfig != "yes" { + ch.logger.Error("Mark tool disabled by config", zap.String("config", toolConfig)) + return nil, errors.New( + "the conversations_mark tool is disabled. " + + "To enable it, set the SLACK_MCP_MARK_TOOL environment variable to true or 1", + ) + } + + channel := request.GetString("channel_id", "") + if channel == "" { + ch.logger.Error("channel_id missing in mark params") + 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 + } + + ts := request.GetString("ts", "") + + return &markParams{ + channel: channel, + ts: ts, + }, nil +} func (ch *ConversationsHandler) parseParamsToolSearch(req mcp.CallToolRequest) (*searchParams, error) { rawQuery := strings.TrimSpace(req.GetString("search_query", "")) freeText, filters := splitQuery(rawQuery) diff --git a/pkg/provider/api.go b/pkg/provider/api.go index 322c8c20..f49479d9 100644 --- a/pkg/provider/api.go +++ b/pkg/provider/api.go @@ -129,6 +129,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 } @@ -155,10 +156,12 @@ type SlackAPI interface { // Used to get channels list from both Slack and Enterprise Grid versions GetConversationsContext(ctx context.Context, params *slack.GetConversationsParameters) ([]slack.Channel, string, error) + GetConversationInfoContext(ctx context.Context, input *slack.GetConversationInfoInput) (*slack.Channel, error) // Edge API methods ClientUserBoot(ctx context.Context) (*edge.ClientUserBootResponse, error) - UsersSearch(ctx context.Context, query string, count int) ([]slack.User, error) +UsersSearch(ctx context.Context, query string, count int) ([]slack.User, error) + ClientCounts(ctx context.Context) (edge.ClientCountsResponse, error) } type MCPSlackClient struct { @@ -348,6 +351,12 @@ func (c *MCPSlackClient) GetConversationsContext(ctx context.Context, params *sl return c.slackClient.GetConversationsContext(ctx, params) } +func (c *MCPSlackClient) GetConversationInfoContext(ctx context.Context, input *slack.GetConversationInfoInput) (*slack.Channel, error) { + // Use standard slack client for OAuth tokens (xoxp/xoxb) + // Note: This is primarily used for the xoxp fallback in ConversationsUnreadsHandler + return c.slackClient.GetConversationInfoContext(ctx, input) +} + func (c *MCPSlackClient) GetConversationHistoryContext(ctx context.Context, params *slack.GetConversationHistoryParameters) (*slack.GetConversationHistoryResponse, error) { return c.slackClient.GetConversationHistoryContext(ctx, params) } @@ -388,6 +397,9 @@ func (c *MCPSlackClient) UsersSearch(ctx context.Context, query string, count in return c.edgeClient.UsersSearch(ctx, query, count) } +func (c *MCPSlackClient) ClientCounts(ctx context.Context) (edge.ClientCountsResponse, error) { + return c.edgeClient.ClientCounts(ctx) +} func (c *MCPSlackClient) IsEnterprise() bool { return c.isEnterprise } @@ -805,7 +817,7 @@ func (ap *ApiProvider) refreshChannelsInternal(ctx context.Context, force bool) 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, ) newSnapshot.Channels[c.ID] = remappedChannel @@ -928,6 +940,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) @@ -1073,7 +1086,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 @@ -1137,6 +1150,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 e69a2ab7..5f279f0d 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -281,6 +281,46 @@ func NewMCPServer(provider *provider.ApiProvider, logger *zap.Logger, enabledToo ), ), conversationsHandler.UsersSearchHandler) + // 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) if shouldAddTool(ToolChannelsList, enabledTools, "") {