Files
Paymahn Moghadasian d781728c6c feat: slack-cli — a no-daemon CLI over the slack-mcp-server engine (#1)
* feat: add slack-cli, a no-daemon CLI over the slack-mcp-server engine

Turn the forked slack-mcp-server into a CLI so running many agents no
longer means one resident MCP process each. Every command is a
short-lived process that reads the shared on-disk cache.

- rename module to github.com/paymog/slack-cli (go install/homebrew/ldflags)
- internal/toolcall: invoke the upstream tool handlers in-process; the only
  mcp-go coupling lives here, so pkg/handler and pkg/provider are reused
  byte-for-byte (clean upstream merges, fork-and-extend)
- internal/{cli,cmds,config,credstore,runtime,output}: cobra command tree,
  keyring-backed credential profiles, provider bootstrap, result printing
- 21 tools as subcommands (channels, conversations, users, usergroups,
  saved, reactions, attachments, cache); write tools keep their env gating
- goreleaser + homebrew release workflow; ships a skills/slack-cli skill
- unit tests for config/credstore/toolcall; MCP server still builds

The MCP server (cmd/slack-mcp-server) is kept intact.

* chore(napkin): record real-workspace verification

* docs: explain how the CLI works (in-process handler invocation, shared cache)
2026-06-26 12:45:38 -05:00

448 lines
12 KiB
Go

package handler
import (
"context"
"encoding/base64"
"fmt"
"sort"
"strings"
"github.com/gocarina/gocsv"
"github.com/paymog/slack-cli/pkg/provider"
"github.com/slack-go/slack"
"github.com/paymog/slack-cli/pkg/server/auth"
"github.com/paymog/slack-cli/pkg/text"
"github.com/mark3labs/mcp-go/mcp"
"go.uber.org/zap"
)
type Channel struct {
ID string `json:"id"`
Name string `json:"name"`
Topic string `json:"topic"`
Purpose string `json:"purpose"`
MemberCount int `json:"memberCount"`
Cursor string `json:"cursor"`
}
type ChannelsHandler struct {
apiProvider *provider.ApiProvider
validTypes map[string]bool
logger *zap.Logger
}
func NewChannelsHandler(apiProvider *provider.ApiProvider, logger *zap.Logger) *ChannelsHandler {
validTypes := make(map[string]bool, len(provider.AllChanTypes))
for _, v := range provider.AllChanTypes {
validTypes[v] = true
}
return &ChannelsHandler{
apiProvider: apiProvider,
validTypes: validTypes,
logger: logger,
}
}
func (ch *ChannelsHandler) ChannelsResource(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
ch.logger.Debug("ChannelsResource called", zap.Any("params", request.Params))
// mark3labs/mcp-go does not support middlewares for resources.
if authenticated, err := auth.IsAuthenticated(ctx, ch.apiProvider.ServerTransport(), ch.logger); !authenticated {
ch.logger.Error("Authentication failed for channels resource", zap.Error(err))
return nil, err
}
var channelList []Channel
if ready, err := ch.apiProvider.IsReady(); !ready {
ch.logger.Error("API provider not ready", zap.Error(err))
return nil, err
}
ar, err := ch.apiProvider.Slack().AuthTest()
if err != nil {
ch.logger.Error("Auth test failed", zap.Error(err))
return nil, err
}
ws, err := text.Workspace(ar.URL)
if err != nil {
ch.logger.Error("Failed to parse workspace from URL",
zap.String("url", ar.URL),
zap.Error(err),
)
return nil, fmt.Errorf("failed to parse workspace from URL: %v", err)
}
channels := ch.apiProvider.ProvideChannelsMaps().Channels
ch.logger.Debug("Retrieved channels from provider", zap.Int("count", len(channels)))
for _, channel := range channels {
channelList = append(channelList, Channel{
ID: channel.ID,
Name: channel.Name,
Topic: channel.Topic,
Purpose: channel.Purpose,
MemberCount: channel.MemberCount,
})
}
csvBytes, err := gocsv.MarshalBytes(&channelList)
if err != nil {
ch.logger.Error("Failed to marshal channels to CSV", zap.Error(err))
return nil, err
}
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: "slack://" + ws + "/channels",
MIMEType: "text/csv",
Text: string(csvBytes),
},
}, nil
}
func (ch *ChannelsHandler) ChannelsHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ch.logger.Debug("ChannelsHandler called")
if ready, err := ch.apiProvider.IsReady(); !ready {
ch.logger.Error("API provider not ready", zap.Error(err))
return nil, err
}
sortType := request.GetString("sort", "popularity")
types := request.GetString("channel_types", provider.PubChanType)
cursor := request.GetString("cursor", "")
limit := request.GetInt("limit", 0)
query := request.GetString("query", "")
queryTargets := request.GetString("query_targets", "name")
ch.logger.Debug("Request parameters",
zap.String("sort", sortType),
zap.String("channel_types", types),
zap.String("cursor", cursor),
zap.Int("limit", limit),
zap.String("query", query),
zap.String("query_targets", queryTargets),
)
// MCP Inspector v0.14.0 has issues with Slice type
// introspection, so some type simplification makes sense here
channelTypes := []string{}
for _, t := range strings.Split(types, ",") {
t = strings.TrimSpace(t)
if ch.validTypes[t] {
channelTypes = append(channelTypes, t)
} else if t != "" {
ch.logger.Warn("Invalid channel type ignored", zap.String("type", t))
}
}
if len(channelTypes) == 0 {
ch.logger.Debug("No valid channel types provided, using defaults")
channelTypes = append(channelTypes, provider.PubChanType)
channelTypes = append(channelTypes, provider.PrivateChanType)
}
ch.logger.Debug("Validated channel types", zap.Strings("types", channelTypes))
if limit == 0 {
limit = 100
ch.logger.Debug("Limit not provided, using default", zap.Int("limit", limit))
}
if limit > 999 {
ch.logger.Warn("Limit exceeds maximum, capping to 999", zap.Int("requested", limit))
limit = 999
}
var (
nextcur string
channelList []Channel
)
allChannels := ch.apiProvider.ProvideChannelsMaps().Channels
ch.logger.Debug("Total channels available", zap.Int("count", len(allChannels)))
channels := filterChannelsByTypes(allChannels, channelTypes)
ch.logger.Debug("Channels after filtering by type", zap.Int("count", len(channels)))
if query != "" {
validTargets := map[string]bool{"name": true, "topic": true, "purpose": true}
targetSet := make(map[string]bool)
for _, t := range strings.Split(queryTargets, ",") {
t = strings.TrimSpace(strings.ToLower(t))
if validTargets[t] {
targetSet[t] = true
} else if t != "" {
ch.logger.Warn("Invalid query target ignored", zap.String("target", t))
}
}
if len(targetSet) == 0 {
ch.logger.Debug("No valid query targets provided, using default (name)")
targetSet["name"] = true
}
channels = filterChannelsByQuery(channels, query, targetSet)
ch.logger.Debug("Channels after keyword filter", zap.Int("count", len(channels)))
}
var chans []provider.Channel
chans, nextcur = paginateChannels(
channels,
cursor,
limit,
)
ch.logger.Debug("Pagination results",
zap.Int("returned_count", len(chans)),
zap.Bool("has_next_page", nextcur != ""),
)
for _, channel := range chans {
channelList = append(channelList, Channel{
ID: channel.ID,
Name: channel.Name,
Topic: channel.Topic,
Purpose: channel.Purpose,
MemberCount: channel.MemberCount,
})
}
switch sortType {
case "popularity":
ch.logger.Debug("Sorting channels by popularity (member count)")
sort.Slice(channelList, func(i, j int) bool {
return channelList[i].MemberCount > channelList[j].MemberCount
})
default:
ch.logger.Debug("No sorting applied", zap.String("sort_type", sortType))
}
if len(channelList) > 0 && nextcur != "" {
channelList[len(channelList)-1].Cursor = nextcur
ch.logger.Debug("Added cursor to last channel", zap.String("cursor", nextcur))
}
csvBytes, err := gocsv.MarshalBytes(&channelList)
if err != nil {
ch.logger.Error("Failed to marshal channels to CSV", zap.Error(err))
return nil, err
}
return mcp.NewToolResultText(string(csvBytes)), nil
}
func (ch *ChannelsHandler) ChannelsMeHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ch.logger.Debug("ChannelsMeHandler called")
types := request.GetString("channel_types", "public_channel,private_channel")
cursor := request.GetString("cursor", "")
limit := request.GetInt("limit", 0)
if limit == 0 {
limit = 100
}
if limit > 999 {
limit = 999
}
channelTypes := []string{}
for _, t := range strings.Split(types, ",") {
t = strings.TrimSpace(t)
if ch.validTypes[t] {
channelTypes = append(channelTypes, t)
}
}
if len(channelTypes) == 0 {
channelTypes = []string{provider.PubChanType, provider.PrivateChanType}
}
// Fetch channels via the Slack API, stopping as soon as we have enough
// results and using the API's native cursor for pagination. This avoids
// fetching every channel the user belongs to on large workspaces.
usersMap := ch.apiProvider.ProvideUsersMap().Users
var allChannels []provider.Channel
var apiCursor string
var slackNextCursor string
if cursor != "" {
apiCursor = cursor
}
for {
params := &slack.GetConversationsForUserParameters{
Types: channelTypes,
Limit: 200,
Cursor: apiCursor,
ExcludeArchived: true,
}
channels, nextCursor, err := ch.apiProvider.Slack().GetConversationsForUserContext(ctx, params)
if err != nil {
ch.logger.Error("Failed to fetch user conversations", zap.Error(err))
return nil, fmt.Errorf("failed to fetch your channels: %v", err)
}
for _, c := range channels {
allChannels = append(allChannels, provider.MapChannelFromSlack(c, usersMap))
}
// Early exit: stop paginating through the Slack API once we have enough.
if len(allChannels) >= limit {
slackNextCursor = nextCursor
break
}
if nextCursor == "" {
break
}
apiCursor = nextCursor
}
ch.logger.Debug("Fetched member channels", zap.Int("count", len(allChannels)))
// Truncate to limit and use the Slack API cursor.
end := limit
if end > len(allChannels) {
end = len(allChannels)
}
var channelList []Channel
for _, channel := range allChannels[:end] {
channelList = append(channelList, Channel{
ID: channel.ID,
Name: channel.Name,
Topic: channel.Topic,
Purpose: channel.Purpose,
MemberCount: channel.MemberCount,
})
}
if len(channelList) > 0 && slackNextCursor != "" {
channelList[len(channelList)-1].Cursor = slackNextCursor
}
csvBytes, err := gocsv.MarshalBytes(&channelList)
if err != nil {
return nil, err
}
return mcp.NewToolResultText(string(csvBytes)), nil
}
func filterChannelsByTypes(channels map[string]provider.Channel, types []string) []provider.Channel {
logger := zap.L()
var result []provider.Channel
typeSet := make(map[string]bool)
for _, t := range types {
typeSet[t] = true
}
publicCount := 0
privateCount := 0
imCount := 0
mpimCount := 0
for _, ch := range channels {
if typeSet["public_channel"] && !ch.IsPrivate && !ch.IsIM && !ch.IsMpIM {
result = append(result, ch)
publicCount++
}
if typeSet["private_channel"] && ch.IsPrivate && !ch.IsIM && !ch.IsMpIM {
result = append(result, ch)
privateCount++
}
if typeSet["im"] && ch.IsIM {
result = append(result, ch)
imCount++
}
if typeSet["mpim"] && ch.IsMpIM {
result = append(result, ch)
mpimCount++
}
}
logger.Debug("Channel filtering complete",
zap.Int("total_input", len(channels)),
zap.Int("total_output", len(result)),
zap.Int("public_channels", publicCount),
zap.Int("private_channels", privateCount),
zap.Int("ims", imCount),
zap.Int("mpims", mpimCount),
)
return result
}
func filterChannelsByQuery(channels []provider.Channel, query string, targetSet map[string]bool) []provider.Channel {
q := strings.ToLower(query)
var result []provider.Channel
for _, ch := range channels {
if (targetSet["name"] && strings.Contains(strings.ToLower(ch.Name), q)) ||
(targetSet["topic"] && strings.Contains(strings.ToLower(ch.Topic), q)) ||
(targetSet["purpose"] && strings.Contains(strings.ToLower(ch.Purpose), q)) {
result = append(result, ch)
}
}
return result
}
func paginateChannels(channels []provider.Channel, cursor string, limit int) ([]provider.Channel, string) {
logger := zap.L()
sort.Slice(channels, func(i, j int) bool {
return channels[i].ID < channels[j].ID
})
startIndex := 0
if cursor != "" {
if decoded, err := base64.StdEncoding.DecodeString(cursor); err == nil {
lastID := string(decoded)
for i, ch := range channels {
if ch.ID > lastID {
startIndex = i
break
}
}
logger.Debug("Decoded cursor",
zap.String("cursor", cursor),
zap.String("decoded_id", lastID),
zap.Int("start_index", startIndex),
)
} else {
logger.Warn("Failed to decode cursor",
zap.String("cursor", cursor),
zap.Error(err),
)
}
}
endIndex := startIndex + limit
if endIndex > len(channels) {
endIndex = len(channels)
}
paged := channels[startIndex:endIndex]
var nextCursor string
if endIndex < len(channels) {
nextCursor = base64.StdEncoding.EncodeToString([]byte(channels[endIndex-1].ID))
logger.Debug("Generated next cursor",
zap.String("last_id", channels[endIndex-1].ID),
zap.String("next_cursor", nextCursor),
)
}
logger.Debug("Pagination complete",
zap.Int("total_channels", len(channels)),
zap.Int("start_index", startIndex),
zap.Int("end_index", endIndex),
zap.Int("page_size", len(paged)),
zap.Bool("has_more", nextCursor != ""),
)
return paged, nextCursor
}