api.go•20.2 kB
package provider
import (
"context"
"encoding/json"
"errors"
"io/ioutil"
"os"
"strings"
"github.com/korotovsky/slack-mcp-server/pkg/limiter"
"github.com/korotovsky/slack-mcp-server/pkg/provider/edge"
"github.com/korotovsky/slack-mcp-server/pkg/transport"
"github.com/rusq/slackdump/v3/auth"
"github.com/slack-go/slack"
"go.uber.org/zap"
"golang.org/x/time/rate"
)
const usersNotReadyMsg = "users cache is not ready yet, sync process is still running... please wait"
const channelsNotReadyMsg = "channels cache is not ready yet, sync process is still running... please wait"
const defaultUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
var AllChanTypes = []string{"mpim", "im", "public_channel", "private_channel"}
var PrivateChanType = "private_channel"
var PubChanType = "public_channel"
var ErrUsersNotReady = errors.New(usersNotReadyMsg)
var ErrChannelsNotReady = errors.New(channelsNotReadyMsg)
type UsersCache struct {
Users map[string]slack.User `json:"users"`
UsersInv map[string]string `json:"users_inv"`
}
type ChannelsCache struct {
Channels map[string]Channel `json:"channels"`
ChannelsInv map[string]string `json:"channels_inv"`
}
type Channel struct {
ID string `json:"id"`
Name string `json:"name"`
Topic string `json:"topic"`
Purpose string `json:"purpose"`
MemberCount int `json:"memberCount"`
IsMpIM bool `json:"mpim"`
IsIM bool `json:"im"`
IsPrivate bool `json:"private"`
}
type SlackAPI interface {
// Standard slack-go API methods
AuthTest() (*slack.AuthTestResponse, error)
AuthTestContext(ctx context.Context) (*slack.AuthTestResponse, error)
GetUsersContext(ctx context.Context, options ...slack.GetUsersOption) ([]slack.User, error)
GetUsersInfo(users ...string) (*[]slack.User, error)
PostMessageContext(ctx context.Context, channel string, options ...slack.MsgOption) (string, string, error)
MarkConversationContext(ctx context.Context, channel, ts string) error
// Used to get messages
GetConversationHistoryContext(ctx context.Context, params *slack.GetConversationHistoryParameters) (*slack.GetConversationHistoryResponse, error)
GetConversationRepliesContext(ctx context.Context, params *slack.GetConversationRepliesParameters) (msgs []slack.Message, hasMore bool, nextCursor string, err error)
SearchContext(ctx context.Context, query string, params slack.SearchParameters) (*slack.SearchMessages, *slack.SearchFiles, error)
// Used to get channels list from both Slack and Enterprise Grid versions
GetConversationsContext(ctx context.Context, params *slack.GetConversationsParameters) ([]slack.Channel, string, error)
// Edge API methods
ClientUserBoot(ctx context.Context) (*edge.ClientUserBootResponse, error)
}
type MCPSlackClient struct {
slackClient *slack.Client
edgeClient *edge.Client
authResponse *slack.AuthTestResponse
authProvider auth.Provider
isEnterprise bool
isOAuth bool
teamEndpoint string
}
type ApiProvider struct {
transport string
client SlackAPI
logger *zap.Logger
rateLimiter *rate.Limiter
users map[string]slack.User
usersInv map[string]string
usersCache string
usersReady bool
channels map[string]Channel
channelsInv map[string]string
channelsCache string
channelsReady bool
}
func NewMCPSlackClient(authProvider auth.Provider, logger *zap.Logger) (*MCPSlackClient, error) {
httpClient := transport.ProvideHTTPClient(authProvider.Cookies(), logger)
slackClient := slack.New(authProvider.SlackToken(),
slack.OptionHTTPClient(httpClient),
)
authResp, err := slackClient.AuthTest()
if err != nil {
return nil, err
}
authResponse := &slack.AuthTestResponse{
URL: authResp.URL,
Team: authResp.Team,
User: authResp.User,
TeamID: authResp.TeamID,
UserID: authResp.UserID,
EnterpriseID: authResp.EnterpriseID,
BotID: authResp.BotID,
}
slackClient = slack.New(authProvider.SlackToken(),
slack.OptionHTTPClient(httpClient),
slack.OptionAPIURL(authResp.URL+"api/"),
)
edgeClient, err := edge.NewWithInfo(authResponse, authProvider,
edge.OptionHTTPClient(httpClient),
)
if err != nil {
return nil, err
}
isEnterprise := authResp.EnterpriseID != ""
return &MCPSlackClient{
slackClient: slackClient,
edgeClient: edgeClient,
authResponse: authResponse,
authProvider: authProvider,
isEnterprise: isEnterprise,
isOAuth: strings.HasPrefix(authProvider.SlackToken(), "xoxp-"),
teamEndpoint: authResp.URL,
}, nil
}
func (c *MCPSlackClient) AuthTest() (*slack.AuthTestResponse, error) {
if os.Getenv("SLACK_MCP_XOXP_TOKEN") == "demo" || (os.Getenv("SLACK_MCP_XOXC_TOKEN") == "demo" && os.Getenv("SLACK_MCP_XOXD_TOKEN") == "demo") {
return &slack.AuthTestResponse{
URL: "https://_.slack.com",
Team: "Demo Team",
User: "Username",
TeamID: "TEAM123456",
UserID: "U1234567890",
EnterpriseID: "",
BotID: "",
}, nil
}
if c.authResponse != nil {
return c.authResponse, nil
}
return c.slackClient.AuthTest()
}
func (c *MCPSlackClient) AuthTestContext(ctx context.Context) (*slack.AuthTestResponse, error) {
return c.slackClient.AuthTestContext(ctx)
}
func (c *MCPSlackClient) GetUsersContext(ctx context.Context, options ...slack.GetUsersOption) ([]slack.User, error) {
return c.slackClient.GetUsersContext(ctx, options...)
}
func (c *MCPSlackClient) GetUsersInfo(users ...string) (*[]slack.User, error) {
return c.slackClient.GetUsersInfo(users...)
}
func (c *MCPSlackClient) MarkConversationContext(ctx context.Context, channel, ts string) error {
return c.slackClient.MarkConversationContext(ctx, channel, ts)
}
func (c *MCPSlackClient) GetConversationsContext(ctx context.Context, params *slack.GetConversationsParameters) ([]slack.Channel, string, error) {
// Please see https://github.com/korotovsky/slack-mcp-server/issues/73
// It seems that `conversations.list` works with `xoxp` tokens within Enterprise Grid setups
// and if `xoxc`/`xoxd` defined we fallback to edge client.
// In non Enterprise Grid setups we always use `conversations.list` api as it accepts both token types wtf.
if c.isEnterprise {
if c.isOAuth {
return c.slackClient.GetConversationsContext(ctx, params)
} else {
edgeChannels, _, err := c.edgeClient.GetConversationsContext(ctx, nil)
if err != nil {
return nil, "", err
}
var channels []slack.Channel
for _, ec := range edgeChannels {
if params != nil && params.ExcludeArchived && ec.IsArchived {
continue
}
channels = append(channels, slack.Channel{
IsGeneral: ec.IsGeneral,
GroupConversation: slack.GroupConversation{
Conversation: slack.Conversation{
ID: ec.ID,
IsIM: ec.IsIM,
IsMpIM: ec.IsMpIM,
IsPrivate: ec.IsPrivate,
Created: slack.JSONTime(ec.Created.Time().UnixMilli()),
Unlinked: ec.Unlinked,
NameNormalized: ec.NameNormalized,
IsShared: ec.IsShared,
IsExtShared: ec.IsExtShared,
IsOrgShared: ec.IsOrgShared,
IsPendingExtShared: ec.IsPendingExtShared,
NumMembers: ec.NumMembers,
},
Name: ec.Name,
IsArchived: ec.IsArchived,
Members: ec.Members,
Topic: slack.Topic{
Value: ec.Topic.Value,
},
Purpose: slack.Purpose{
Value: ec.Purpose.Value,
},
},
})
}
return channels, "", nil
}
}
return c.slackClient.GetConversationsContext(ctx, params)
}
func (c *MCPSlackClient) GetConversationHistoryContext(ctx context.Context, params *slack.GetConversationHistoryParameters) (*slack.GetConversationHistoryResponse, error) {
return c.slackClient.GetConversationHistoryContext(ctx, params)
}
func (c *MCPSlackClient) GetConversationRepliesContext(ctx context.Context, params *slack.GetConversationRepliesParameters) (msgs []slack.Message, hasMore bool, nextCursor string, err error) {
return c.slackClient.GetConversationRepliesContext(ctx, params)
}
func (c *MCPSlackClient) SearchContext(ctx context.Context, query string, params slack.SearchParameters) (*slack.SearchMessages, *slack.SearchFiles, error) {
return c.slackClient.SearchContext(ctx, query, params)
}
func (c *MCPSlackClient) PostMessageContext(ctx context.Context, channelID string, options ...slack.MsgOption) (string, string, error) {
return c.slackClient.PostMessageContext(ctx, channelID, options...)
}
func (c *MCPSlackClient) ClientUserBoot(ctx context.Context) (*edge.ClientUserBootResponse, error) {
return c.edgeClient.ClientUserBoot(ctx)
}
func (c *MCPSlackClient) IsEnterprise() bool {
return c.isEnterprise
}
func (c *MCPSlackClient) AuthResponse() *slack.AuthTestResponse {
return c.authResponse
}
func (c *MCPSlackClient) Raw() struct {
Slack *slack.Client
Edge *edge.Client
} {
return struct {
Slack *slack.Client
Edge *edge.Client
}{
Slack: c.slackClient,
Edge: c.edgeClient,
}
}
func New(transport string, logger *zap.Logger) *ApiProvider {
var (
authProvider auth.ValueAuth
err error
)
// Check for XOXP token first (User OAuth)
xoxpToken := os.Getenv("SLACK_MCP_XOXP_TOKEN")
if xoxpToken != "" {
authProvider, err = auth.NewValueAuth(xoxpToken, "")
if err != nil {
logger.Fatal("Failed to create auth provider with XOXP token", zap.Error(err))
}
return newWithXOXP(transport, authProvider, logger)
}
// Fall back to XOXC/XOXD tokens (session-based)
xoxcToken := os.Getenv("SLACK_MCP_XOXC_TOKEN")
xoxdToken := os.Getenv("SLACK_MCP_XOXD_TOKEN")
if xoxcToken == "" || xoxdToken == "" {
logger.Fatal("Authentication required: Either SLACK_MCP_XOXP_TOKEN (User OAuth) or both SLACK_MCP_XOXC_TOKEN and SLACK_MCP_XOXD_TOKEN (session-based) environment variables must be provided")
}
authProvider, err = auth.NewValueAuth(xoxcToken, xoxdToken)
if err != nil {
logger.Fatal("Failed to create auth provider with XOXC/XOXD tokens", zap.Error(err))
}
return newWithXOXC(transport, authProvider, logger)
}
func newWithXOXP(transport string, authProvider auth.ValueAuth, logger *zap.Logger) *ApiProvider {
var (
client *MCPSlackClient
err error
)
usersCache := os.Getenv("SLACK_MCP_USERS_CACHE")
if usersCache == "" {
usersCache = ".users_cache.json"
}
channelsCache := os.Getenv("SLACK_MCP_CHANNELS_CACHE")
if channelsCache == "" {
channelsCache = ".channels_cache_v2.json"
}
if os.Getenv("SLACK_MCP_XOXP_TOKEN") == "demo" || (os.Getenv("SLACK_MCP_XOXC_TOKEN") == "demo" && os.Getenv("SLACK_MCP_XOXD_TOKEN") == "demo") {
logger.Info("Demo credentials are set, skip.")
} else {
client, err = NewMCPSlackClient(authProvider, logger)
if err != nil {
logger.Fatal("Failed to create MCP Slack client", zap.Error(err))
}
}
return &ApiProvider{
transport: transport,
client: client,
logger: logger,
rateLimiter: limiter.Tier2.Limiter(),
users: make(map[string]slack.User),
usersInv: map[string]string{},
usersCache: usersCache,
channels: make(map[string]Channel),
channelsInv: map[string]string{},
channelsCache: channelsCache,
}
}
func newWithXOXC(transport string, authProvider auth.ValueAuth, logger *zap.Logger) *ApiProvider {
var (
client *MCPSlackClient
err error
)
usersCache := os.Getenv("SLACK_MCP_USERS_CACHE")
if usersCache == "" {
usersCache = ".users_cache.json"
}
channelsCache := os.Getenv("SLACK_MCP_CHANNELS_CACHE")
if channelsCache == "" {
channelsCache = ".channels_cache_v2.json"
}
if os.Getenv("SLACK_MCP_XOXP_TOKEN") == "demo" || (os.Getenv("SLACK_MCP_XOXC_TOKEN") == "demo" && os.Getenv("SLACK_MCP_XOXD_TOKEN") == "demo") {
logger.Info("Demo credentials are set, skip.")
} else {
client, err = NewMCPSlackClient(authProvider, logger)
if err != nil {
logger.Fatal("Failed to create MCP Slack client", zap.Error(err))
}
}
return &ApiProvider{
transport: transport,
client: client,
logger: logger,
rateLimiter: limiter.Tier2.Limiter(),
users: make(map[string]slack.User),
usersInv: map[string]string{},
usersCache: usersCache,
channels: make(map[string]Channel),
channelsInv: map[string]string{},
channelsCache: channelsCache,
}
}
func (ap *ApiProvider) RefreshUsers(ctx context.Context) error {
var (
list []slack.User
usersCounter = 0
optionLimit = slack.GetUsersOptionLimit(1000)
)
if data, err := ioutil.ReadFile(ap.usersCache); err == nil {
var cachedUsers []slack.User
if err := json.Unmarshal(data, &cachedUsers); err != nil {
ap.logger.Warn("Failed to unmarshal users cache, will refetch",
zap.String("cache_file", ap.usersCache),
zap.Error(err))
} else {
for _, u := range cachedUsers {
ap.users[u.ID] = u
ap.usersInv[u.Name] = u.ID
}
ap.logger.Info("Loaded users from cache",
zap.Int("count", len(cachedUsers)),
zap.String("cache_file", ap.usersCache))
ap.usersReady = true
return nil
}
}
users, err := ap.client.GetUsersContext(ctx,
optionLimit,
)
if err != nil {
ap.logger.Error("Failed to fetch users", zap.Error(err))
return err
} else {
list = append(list, users...)
}
for _, user := range users {
ap.users[user.ID] = user
ap.usersInv[user.Name] = user.ID
usersCounter++
}
users, err = ap.GetSlackConnect(ctx)
if err != nil {
ap.logger.Error("Failed to fetch users from Slack Connect", zap.Error(err))
return err
} else {
list = append(list, users...)
}
for _, user := range users {
ap.users[user.ID] = user
ap.usersInv[user.Name] = user.ID
usersCounter++
}
if data, err := json.MarshalIndent(list, "", " "); err != nil {
ap.logger.Error("Failed to marshal users for cache", zap.Error(err))
} else {
if err := ioutil.WriteFile(ap.usersCache, data, 0644); err != nil {
ap.logger.Error("Failed to write cache file",
zap.String("cache_file", ap.usersCache),
zap.Error(err))
} else {
ap.logger.Info("Wrote users to cache",
zap.Int("count", usersCounter),
zap.String("cache_file", ap.usersCache))
}
}
ap.usersReady = true
return nil
}
func (ap *ApiProvider) RefreshChannels(ctx context.Context) error {
if data, err := ioutil.ReadFile(ap.channelsCache); err == nil {
var cachedChannels []Channel
if err := json.Unmarshal(data, &cachedChannels); err != nil {
ap.logger.Warn("Failed to unmarshal channels cache, will refetch",
zap.String("cache_file", ap.channelsCache),
zap.Error(err))
} else {
for _, c := range cachedChannels {
ap.channels[c.ID] = c
ap.channelsInv[c.Name] = c.ID
}
ap.logger.Info("Loaded channels from cache",
zap.Int("count", len(cachedChannels)),
zap.String("cache_file", ap.channelsCache))
ap.channelsReady = true
return nil
}
}
channels := ap.GetChannels(ctx, AllChanTypes)
if data, err := json.MarshalIndent(channels, "", " "); err != nil {
ap.logger.Error("Failed to marshal channels for cache", zap.Error(err))
} else {
if err := ioutil.WriteFile(ap.channelsCache, data, 0644); err != nil {
ap.logger.Error("Failed to write cache file",
zap.String("cache_file", ap.channelsCache),
zap.Error(err))
} else {
ap.logger.Info("Wrote channels to cache",
zap.Int("count", len(channels)),
zap.String("cache_file", ap.channelsCache))
}
}
ap.channelsReady = true
return nil
}
func (ap *ApiProvider) GetSlackConnect(ctx context.Context) ([]slack.User, error) {
boot, err := ap.client.ClientUserBoot(ctx)
if err != nil {
ap.logger.Error("Failed to fetch client user boot", zap.Error(err))
return nil, err
}
var collectedIDs []string
for _, im := range boot.IMs {
if !im.IsShared && !im.IsExtShared {
continue
}
_, ok := ap.users[im.User]
if !ok {
collectedIDs = append(collectedIDs, im.User)
}
}
res := make([]slack.User, 0, len(collectedIDs))
if len(collectedIDs) > 0 {
usersInfo, err := ap.client.GetUsersInfo(strings.Join(collectedIDs, ","))
if err != nil {
ap.logger.Error("Failed to fetch users info for shared IMs", zap.Error(err))
return nil, err
}
for _, u := range *usersInfo {
res = append(res, u)
}
}
return res, nil
}
func (ap *ApiProvider) GetChannelsType(ctx context.Context, channelType string) []Channel {
params := &slack.GetConversationsParameters{
Types: []string{channelType},
Limit: 999,
ExcludeArchived: true,
}
var (
channels []slack.Channel
chans []Channel
nextcur string
err error
)
for {
if err := ap.rateLimiter.Wait(ctx); err != nil {
ap.logger.Error("Rate limiter wait failed", zap.Error(err))
return nil
}
channels, nextcur, err = ap.client.GetConversationsContext(ctx, params)
ap.logger.Debug("Fetched channels for ",
zap.String("channelType", channelType),
zap.Int("count", len(channels)),
)
if err != nil {
ap.logger.Error("Failed to fetch channels", zap.Error(err))
break
}
for _, channel := range channels {
ch := mapChannel(
channel.ID,
channel.Name,
channel.NameNormalized,
channel.Topic.Value,
channel.Purpose.Value,
channel.User,
channel.Members,
channel.NumMembers,
channel.IsIM,
channel.IsMpIM,
channel.IsPrivate,
ap.ProvideUsersMap().Users,
)
chans = append(chans, ch)
}
if nextcur == "" {
break
}
params.Cursor = nextcur
}
return chans
}
func (ap *ApiProvider) GetChannels(ctx context.Context, channelTypes []string) []Channel {
if len(channelTypes) == 0 {
channelTypes = AllChanTypes
}
var chans []Channel
for _, t := range AllChanTypes {
var typeChannels = ap.GetChannelsType(ctx, t)
chans = append(chans, typeChannels...)
}
for _, ch := range chans {
ap.channels[ch.ID] = ch
ap.channelsInv[ch.Name] = ch.ID
}
var res []Channel
for _, t := range channelTypes {
for _, channel := range ap.channels {
if t == "public_channel" && !channel.IsPrivate {
res = append(res, channel)
}
if t == "private_channel" && channel.IsPrivate {
res = append(res, channel)
}
if t == "im" && channel.IsIM {
res = append(res, channel)
}
if t == "mpim" && channel.IsMpIM {
res = append(res, channel)
}
}
}
return res
}
func (ap *ApiProvider) ProvideUsersMap() *UsersCache {
return &UsersCache{
Users: ap.users,
UsersInv: ap.usersInv,
}
}
func (ap *ApiProvider) ProvideChannelsMaps() *ChannelsCache {
return &ChannelsCache{
Channels: ap.channels,
ChannelsInv: ap.channelsInv,
}
}
func (ap *ApiProvider) IsReady() (bool, error) {
if !ap.usersReady {
return false, ErrUsersNotReady
}
if !ap.channelsReady {
return false, ErrChannelsNotReady
}
return true, nil
}
func (ap *ApiProvider) ServerTransport() string {
return ap.transport
}
func (ap *ApiProvider) Slack() SlackAPI {
return ap.client
}
func mapChannel(
id, name, nameNormalized, topic, purpose, user string,
members []string,
numMembers int,
isIM, isMpIM, isPrivate bool,
usersMap map[string]slack.User,
) Channel {
channelName := name
finalPurpose := purpose
finalTopic := topic
finalMemberCount := numMembers
if isIM {
finalMemberCount = 2
if u, ok := usersMap[user]; ok {
channelName = "@" + u.Name
finalPurpose = "DM with " + u.RealName
} else {
channelName = "@" + user
finalPurpose = "DM with " + user
}
finalTopic = ""
} else if isMpIM {
if len(members) > 0 {
finalMemberCount = len(members)
var userNames []string
for _, uid := range members {
if u, ok := usersMap[uid]; ok {
userNames = append(userNames, u.RealName)
} else {
userNames = append(userNames, uid)
}
}
channelName = "@" + nameNormalized
finalPurpose = "Group DM with " + strings.Join(userNames, ", ")
finalTopic = ""
}
} else {
channelName = "#" + nameNormalized
}
return Channel{
ID: id,
Name: channelName,
Topic: finalTopic,
Purpose: finalPurpose,
MemberCount: finalMemberCount,
IsIM: isIM,
IsMpIM: isMpIM,
IsPrivate: isPrivate,
}
}