Skip to content

Conversation

@Flare576
Copy link
Contributor

Summary

Rebases @saoudrizwan's work from #146 onto current master, as requested by @korotovsky.

Original PR: #146 by @saoudrizwan - all credit for the feature design and implementation goes to them.

What this PR does:


New Tools (from #146)

conversations_unreads

Efficiently retrieves unread messages across all channels using a single API call.

  • Prioritizes: DMs → Partner channels (Slack Connect) → Internal channels
  • Supports filtering by channel type, mentions-only mode
  • Returns CSV with channel summary or full messages

conversations_mark

Marks a channel or DM as read.

  • Gated by SLACK_MCP_MARK_TOOL env var (disabled by default)
  • Supports channel IDs, #channel-name, and @username

Conflict Resolutions

File Resolution
README.md Renumbered sections: reactions_add stays #6, unreads becomes #7, mark becomes #8
conversations.go (structs) Kept all param structs: addReactionParams, filesGetParams, unreadsParams, markParams
conversations.go (parsers) Kept all parser functions for above structs

Testing

  • ✅ Build passes
  • ✅ All 10 tools register correctly
  • ✅ Unit tests pass (integration tests require credentials, expected to skip)
  • conversations_unreads returns valid CSV
  • attachment_get_data still works after rebase

Ready for review and merge when you are, @korotovsky!

@korotovsky
Copy link
Owner

Thanks for cherry picking. One of the blockers on #146 was to manually test xoxb and xoxp tokens. Could you please do before we merge? The amount of changes is massive and we must be very careful.

@Flare576
Copy link
Contributor Author

Tested with both xoxp and xoxb tokens on a fresh test workspace. Here's what we found:

✅ Working

Tool xoxp xoxb
conversations_history
conversations_unreads ⚠️ ⚠️
conversations_mark

Details

conversations_history - Works perfectly with both token types. Returns messages with all metadata including our new AttachmentIDs field.

conversations_unreads - Executes without error but returns empty results. This is likely because client.counts is an undocumented browser-session API that may only work with xoxc/xoxd tokens. The tool doesn't crash - it just returns no unreads.

conversations_mark - Returns missing_scope error. This requires channels:write which is a User Token Scope (not Bot Token Scope), so typical bot deployments won't have access. This is expected behavior - marking messages as read is inherently a user-level action.

Test Environment

  • Fresh Slack workspace with test app
  • Bot scopes: channels:read, channels:history
  • User scopes: channels:read, channels:history
  • Posted test messages via bot, verified visible in history

Recommendation

The conversations_unreads limitation is inherent to how client.counts works - it's designed for browser sessions. This should probably be documented:

Note: conversations_unreads works best with browser session tokens (xoxc/xoxd). With standard xoxp/xoxb tokens, the tool will execute but may return empty results since the underlying client.counts API is session-based.

For conversations_mark, documenting the channels:write user scope requirement would help users understand why it might fail.

Let me know if you'd like us to add these notes to the README or if you want to test differently!

@korotovsky
Copy link
Owner

@Flare576 please could you figure out, case by case basis what kind of issues we have here and what needs to be fixed before we merge it. Because according to your last message, we can't merge it as is.

@Flare576
Copy link
Contributor Author

Did more digging. Here's the actual status:

conversations_mark - WORKS with xoxp

My earlier test was wrong. Just verified:

curl -X POST "https://slack.com/api/conversations.mark" \
  -H "Authorization: Bearer xoxp-..." \
  -d "channel=C0ABNAGCV19&ts=1769789134.019699"
→ {"ok": true}

And through the MCP:

conversations_mark(channel_id="C0ABNAGCV19", ts="1769789134.019699")
→ "Marked C0ABNAGCV19 as read up to 1769789134.019699"

No issues here. Works with standard xoxp tokens.

conversations_unreads - Requires browser tokens (xoxc/xoxd)

The underlying client.counts API explicitly rejects xoxp/xoxb tokens:

curl -X POST "https://slack.com/api/client.counts" \
  -H "Authorization: Bearer xoxp-..."
→ {"ok": false, "error": "not_allowed_token_type"}

This is an undocumented browser-session API - there's no official Slack API to get unread counts. conversations.list doesn't include unread info either.

Options for conversations_unreads

  1. Document the limitation - Tool only works with browser tokens (xoxc/xoxd stealth mode)
  2. Fallback implementation - Iterate all channels and compare last_read with latest message timestamp (expensive, N API calls)
  3. Gate the tool - Only register it when using stealth mode tokens

My recommendation: Option 1 - document it clearly. Users choosing stealth mode get this feature; users with standard tokens don't. The fallback would be too expensive for large workspaces.

Summary

Tool xoxp xoxb xoxc/xoxd
conversations_mark ❓ untested
conversations_unreads not_allowed_token_type

The PR is safe to merge if we document that conversations_unreads requires stealth mode. Want me to add that to the README?

@niebloomj
Copy link

niebloomj commented Feb 11, 2026

I was able to successfully build a method in python to get a list of unread conversations using an xoxp by iterating and checking unread count

def is_read(conv: Conversation) -> bool:
    logfire.info("Cache miss for is_read")
    client = get_slack_client()
    response: ConversationInfoResponse = client.conversations_info(
        channel=conv.id
    )

    # Check unread_count directly from the typed response
    return (
        response.channel.unread_count == 0
        # Some channels don't have an unread count, not because they are read
        # but because the channel doesn't support unread counts (lack of permissions, etc.)
        if response.channel.unread_count is not None
        else False
    )

def fetch_conversations(
    # Direct messages only (mpim = multi-person IMs disabled)
    types: str = "im",
    unread_only: bool = False,
) -> Generator[Conversation, Any, None]:
    """
    Fetches direct messages and group DMs from Slack.

    Returns:
        Iterator[Conversation]: Iterator of conversation objects.
    """
    cursor: str | None = None

    while tupled_result := get_conversations(cursor, types):
        channels, cursor = tupled_result
        logfire.info(f"next_cursor: {cursor}")

        yield from (
            channel
            for channel in channels
            if not unread_only or not is_read(channel)
        )

        logfire.info(f"Yielded {len(channels)} channels")

        if not cursor:
            break

@niebloomj
Copy link

niebloomj commented Feb 11, 2026

I made a PR stacked on top of this one that adds support for XOXP #199

@Flare576
Copy link
Contributor Author

Thanks for the suggestion @niebloomj! We've implemented a fallback for xoxp tokens using conversations.info to get unread counts.

How it works:

  • Browser tokens (xoxc/xoxd): Uses the fast client.counts API (single call)
  • OAuth tokens (xoxp): Falls back to iterating channels via conversations.info when client.counts returns not_allowed_token_type

The fallback respects the max_channels parameter to limit API calls for large workspaces.

Verified working with both token types:

  • xoxc/xoxd: Fast path works ✅
  • xoxp: Fallback path works ✅

Commit: b94448d

@niebloomj
Copy link

niebloomj commented Feb 11, 2026

Thanks for the suggestion @niebloomj! We've implemented a fallback for xoxp tokens using conversations.info to get unread counts.

Hey @Flare576 ! Thanks for implementing the xoxp fallback - glad the approach worked!

Would you be open to adding a co-author credit for the implementation? You can amend the commit (b94448d) to include:

Co-authored-by: @niebloomj [email protected]

No worries if not - happy to help either way! 🙂

@Flare576 Flare576 force-pushed the feat-unreads-rebase branch from b94448d to dab94ff Compare February 11, 2026 01:57
@Flare576
Copy link
Contributor Author

Done! Added the co-author credit - thanks again for the approach @niebloomj! 🙌

Updated commit: dab94ff

@niebloomj
Copy link

Done! Added the co-author credit - thanks again for the approach @niebloomj! 🙌

Updated commit: dab94ff

Thank you! I am very excited about this PR :)

@140am
Copy link

140am commented Feb 11, 2026

hey, been testing this on a production workspace with 100+ channels/DMs using both xoxc/xoxd and xoxp tokens. great work on the feature — unreads is super useful

found an issue where the xoxp fallback path silently never triggers. tl;dr: ClientCounts doesn't validate the ok field in Slack's response, so xoxp gets HTTP 200 with {"ok": false} back, no Go error is raised, and the fallback never runs — xoxp users see "0 unreads" silently

the root cause is in the edge client — ClientCounts doesn't call baseResponse.validate() after ParseResponse, so when client.counts rejects an xoxp token, Slack returns HTTP 200 with {"ok": false, "error": "not_allowed_token_type"}ParseResponse only checks HTTP status and JSON parsing, never the ok field. so ClientCounts returns (emptyResponse, nil) — no Go error — and the handler's if err != nil check (conversations.go L641-644) passes through to processClientCountsResponse with zero channels. result: xoxp users get "0 unreads" with no error, no fallback

fwiw this pattern extends to other edge methods too — only search.go calls validate() after ParseResponse. ClientUserBoot, ClientDMs, etc. skip it

one-line fix in pkg/provider/edge/client.go:

 	r := ClientCountsResponse{}
 	if err := cl.ParseResponse(&r, resp); err != nil {
 		return ClientCountsResponse{}, err
 	}
+	if err := r.validate("client.counts"); err != nil {
+		return ClientCountsResponse{}, err
+	}
 	return r, nil

verified by testing both paths end-to-end through the MCP tool:

  • without fix: xoxp returns empty CSV, no error, no fallback
  • with fix: fallback triggers correctly, finds unreads via conversations.info

also safe for the other caller of ClientCounts in slacker.go L82 — that code path only runs with browser tokens, so client.counts succeeds there. xoxp users take the OAuth path in api.go L306-307 and never hit it

separate follow-up note on the xoxp fallback's scalability: getUnreadsViaConversationsInfo iterates channelsMaps.Channels which is a Go map containing all conversation types (channels, DMs, group DMs, MPIMs). the maxChannels * 2 cap (default 100) only samples a fraction of larger workspaces, and since Go maps iterate in random order, which conversations get checked changes every call — DMs can be missed one run and appear the next. each entry is also a separate conversations.info API call. might be worth pre-sorting by type (DMs first) so the cap doesn't randomly cut off the most important conversations

@140am
Copy link

140am commented Feb 11, 2026

couple more things from testing this on the same workspace:

1. bug: range-by-value in the message-fetch loop

the message-fetch loop uses for _, uc := range unreadChannels, so when it updates uc.UnreadCount after fetching history, the write goes to a copy — the original slice element stays at 0. the messages themselves are fetched fine (appended to a separate slice), so the bug only surfaces in the unread count column

2. filtering muted channels

this was the biggest noise source in practice — the majority of channels showing as "unread" were actually muted. mute state isn't in client.counts, conversations.info, or conversations.list — it's only in users.prefs.getall_notifications_prefs (a JSON string) → channels[channelID].muted. fetching that once and filtering by default with an include_muted opt-in made the output way more usable

3. backfilling real unread counts for channels

UnreadCount is set from snap.MentionCount, but MentionCount is only non-zero for @mentions. regular channel activity shows as "has unreads" with count 0. one approach: for HasUnreads=true && MentionCount=0, call conversations.history(oldest=lastRead, limit=20) and count the messages

i've got all three working and tested — happy to open a PR against your branch if useful

saoudrizwan and others added 8 commits February 11, 2026 17:09
…rieval

- Uses ClientUserBoot to get all channels with LastRead/Latest in one API call
- Filters channels where Latest > LastRead to find unreads
- Prioritizes: DMs > group DMs > partner channels (ext-*) > internal
- Only fetches message history for channels with actual unreads
- Supports filtering by channel type and configurable limits

Addresses issue korotovsky#114
- Switch from ClientUserBoot to ClientCounts API
- ClientCounts returns HasUnreads boolean for all channels
- Add ClientCounts to SlackAPI interface
- Process Channels, MPIMs, and IMs separately
- Strip existing # prefix before adding to avoid ##name
- Use stripped name for ext-/shared- prefix checks for partner type
- Add mentions_only parameter to conversations_unreads to filter
  channels to only those with @mentions (priority inbox)
- Add conversations_mark tool to mark channels/DMs as read
  - Supports channel IDs, #channel names, and @username
  - If no timestamp provided, marks all messages as read
…nels

- Add IsExtShared field to Channel struct in cache
- Pass IsExtShared through mapChannel function
- Use cached.IsExtShared to identify external/partner channels
  instead of checking for ext-/shared- name prefixes

Note: Users may need to delete their channels cache file to repopulate
with the new IsExtShared field.
- Extract handler params to structs with parsing functions
  (unreadsParams, markParams) following existing patterns
- Add SLACK_MCP_MARK_TOOL env var guard for conversations_mark
  (disabled by default, requires explicit opt-in)
- Remove unused categorizeChannel and getChannelDisplayName functions
- Update README with conversations_mark safety note and env var docs
@Flare576 Flare576 force-pushed the feat-unreads-rebase branch from dab94ff to e238772 Compare February 11, 2026 23:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants