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, "") {