Skip to main content
Glama

Slack MCP

MIT License
696
  • Apple
  • Linux
conversations.go31.8 kB
package handler import ( "context" "encoding/base64" "errors" "fmt" "net/url" "os" "regexp" "strconv" "strings" "time" "github.com/gocarina/gocsv" "github.com/korotovsky/slack-mcp-server/pkg/provider" "github.com/korotovsky/slack-mcp-server/pkg/server/auth" "github.com/korotovsky/slack-mcp-server/pkg/text" "github.com/mark3labs/mcp-go/mcp" "github.com/slack-go/slack" slackGoUtil "github.com/takara2314/slack-go-util" "go.uber.org/zap" ) const ( defaultConversationsNumericLimit = 50 defaultConversationsExpressionLimit = "1d" ) var validFilterKeys = map[string]struct{}{ "is": {}, "in": {}, "from": {}, "with": {}, "before": {}, "after": {}, "on": {}, "during": {}, } type Message struct { MsgID string `json:"msgID"` UserID string `json:"userID"` UserName string `json:"userUser"` RealName string `json:"realName"` Channel string `json:"channelID"` ThreadTs string `json:"ThreadTs"` Text string `json:"text"` Time string `json:"time"` Reactions string `json:"reactions,omitempty"` Cursor string `json:"cursor"` } type User struct { UserID string `json:"userID"` UserName string `json:"userName"` RealName string `json:"realName"` } type conversationParams struct { channel string limit int oldest string latest string cursor string activity bool } type searchParams struct { query string limit int page int } type addMessageParams struct { channel string threadTs string text string contentType string } type ConversationsHandler struct { apiProvider *provider.ApiProvider logger *zap.Logger } func NewConversationsHandler(apiProvider *provider.ApiProvider, logger *zap.Logger) *ConversationsHandler { return &ConversationsHandler{ apiProvider: apiProvider, logger: logger, } } // UsersResource streams a CSV of all users func (ch *ConversationsHandler) UsersResource(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { ch.logger.Debug("UsersResource called", zap.Any("params", request.Params)) // authentication if authenticated, err := auth.IsAuthenticated(ctx, ch.apiProvider.ServerTransport(), ch.logger); !authenticated { ch.logger.Error("Authentication failed for users resource", zap.Error(err)) return nil, err } // provider readiness if ready, err := ch.apiProvider.IsReady(); !ready { ch.logger.Error("API provider not ready", zap.Error(err)) return nil, err } // Slack auth test ar, err := ch.apiProvider.Slack().AuthTest() if err != nil { ch.logger.Error("Slack AuthTest 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) } // collect users usersMaps := ch.apiProvider.ProvideUsersMap() users := usersMaps.Users usersList := make([]User, 0, len(users)) for _, user := range users { usersList = append(usersList, User{ UserID: user.ID, UserName: user.Name, RealName: user.RealName, }) } // marshal CSV csvBytes, err := gocsv.MarshalBytes(&usersList) if err != nil { ch.logger.Error("Failed to marshal users to CSV", zap.Error(err)) return nil, err } return []mcp.ResourceContents{ mcp.TextResourceContents{ URI: "slack://" + ws + "/users", MIMEType: "text/csv", Text: string(csvBytes), }, }, nil } // ConversationsAddMessageHandler posts a message and returns it as CSV func (ch *ConversationsHandler) ConversationsAddMessageHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { ch.logger.Debug("ConversationsAddMessageHandler called", zap.Any("params", request.Params)) params, err := ch.parseParamsToolAddMessage(request) if err != nil { ch.logger.Error("Failed to parse add-message params", zap.Error(err)) return nil, err } var options []slack.MsgOption if params.threadTs != "" { options = append(options, slack.MsgOptionTS(params.threadTs)) } switch params.contentType { case "text/plain": options = append(options, slack.MsgOptionDisableMarkdown()) options = append(options, slack.MsgOptionText(params.text, false)) case "text/markdown": blocks, err := slackGoUtil.ConvertMarkdownTextToBlocks(params.text) if err != nil { ch.logger.Warn("Markdown parsing error", zap.Error(err)) options = append(options, slack.MsgOptionDisableMarkdown()) options = append(options, slack.MsgOptionText(params.text, false)) } else { options = append(options, slack.MsgOptionBlocks(blocks...)) } default: return nil, errors.New("content_type must be either 'text/plain' or 'text/markdown'") } unfurlOpt := os.Getenv("SLACK_MCP_ADD_MESSAGE_UNFURLING") if text.IsUnfurlingEnabled(params.text, unfurlOpt, ch.logger) { options = append(options, slack.MsgOptionEnableLinkUnfurl()) } else { options = append(options, slack.MsgOptionDisableLinkUnfurl()) options = append(options, slack.MsgOptionDisableMediaUnfurl()) } ch.logger.Debug("Posting Slack message", zap.String("channel", params.channel), zap.String("thread_ts", params.threadTs), zap.String("content_type", params.contentType), ) respChannel, respTimestamp, err := ch.apiProvider.Slack().PostMessageContext(ctx, params.channel, options...) if err != nil { ch.logger.Error("Slack PostMessageContext failed", zap.Error(err)) return nil, err } toolConfig := os.Getenv("SLACK_MCP_ADD_MESSAGE_MARK") if toolConfig == "1" || toolConfig == "true" || toolConfig == "yes" { err := ch.apiProvider.Slack().MarkConversationContext(ctx, params.channel, respTimestamp) if err != nil { ch.logger.Error("Slack MarkConversationContext failed", zap.Error(err)) return nil, err } } // fetch the single message we just posted historyParams := slack.GetConversationHistoryParameters{ ChannelID: respChannel, Limit: 1, Oldest: respTimestamp, Latest: respTimestamp, Inclusive: true, } history, err := ch.apiProvider.Slack().GetConversationHistoryContext(ctx, &historyParams) if err != nil { ch.logger.Error("GetConversationHistoryContext failed", zap.Error(err)) return nil, err } ch.logger.Debug("Fetched conversation history", zap.Int("message_count", len(history.Messages))) messages := ch.convertMessagesFromHistory(history.Messages, historyParams.ChannelID, false) return marshalMessagesToCSV(messages) } // ConversationsHistoryHandler streams conversation history as CSV func (ch *ConversationsHandler) ConversationsHistoryHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { ch.logger.Debug("ConversationsHistoryHandler called", zap.Any("params", request.Params)) params, err := ch.parseParamsToolConversations(request) if err != nil { ch.logger.Error("Failed to parse history params", zap.Error(err)) return nil, err } ch.logger.Debug("History params parsed", zap.String("channel", params.channel), zap.Int("limit", params.limit), zap.String("oldest", params.oldest), zap.String("latest", params.latest), zap.Bool("include_activity", params.activity), ) historyParams := slack.GetConversationHistoryParameters{ ChannelID: params.channel, Limit: params.limit, Oldest: params.oldest, Latest: params.latest, Cursor: params.cursor, Inclusive: false, } history, err := ch.apiProvider.Slack().GetConversationHistoryContext(ctx, &historyParams) if err != nil { ch.logger.Error("GetConversationHistoryContext failed", zap.Error(err)) return nil, err } ch.logger.Debug("Fetched conversation history", zap.Int("message_count", len(history.Messages))) messages := ch.convertMessagesFromHistory(history.Messages, params.channel, params.activity) if len(messages) > 0 && history.HasMore { messages[len(messages)-1].Cursor = history.ResponseMetaData.NextCursor } return marshalMessagesToCSV(messages) } // ConversationsRepliesHandler streams thread replies as CSV func (ch *ConversationsHandler) ConversationsRepliesHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { ch.logger.Debug("ConversationsRepliesHandler called", zap.Any("params", request.Params)) params, err := ch.parseParamsToolConversations(request) if err != nil { ch.logger.Error("Failed to parse replies params", zap.Error(err)) return nil, err } threadTs := request.GetString("thread_ts", "") if threadTs == "" { ch.logger.Error("thread_ts not provided for replies", zap.String("thread_ts", threadTs)) return nil, errors.New("thread_ts must be a string") } repliesParams := slack.GetConversationRepliesParameters{ ChannelID: params.channel, Timestamp: threadTs, Limit: params.limit, Oldest: params.oldest, Latest: params.latest, Cursor: params.cursor, Inclusive: false, } replies, hasMore, nextCursor, err := ch.apiProvider.Slack().GetConversationRepliesContext(ctx, &repliesParams) if err != nil { ch.logger.Error("GetConversationRepliesContext failed", zap.Error(err)) return nil, err } ch.logger.Debug("Fetched conversation replies", zap.Int("count", len(replies))) messages := ch.convertMessagesFromHistory(replies, params.channel, params.activity) if len(messages) > 0 && hasMore { messages[len(messages)-1].Cursor = nextCursor } return marshalMessagesToCSV(messages) } func (ch *ConversationsHandler) ConversationsSearchHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { ch.logger.Debug("ConversationsSearchHandler called", zap.Any("params", request.Params)) params, err := ch.parseParamsToolSearch(request) if err != nil { ch.logger.Error("Failed to parse search params", zap.Error(err)) return nil, err } ch.logger.Debug("Search params parsed", zap.String("query", params.query), zap.Int("limit", params.limit), zap.Int("page", params.page)) searchParams := slack.SearchParameters{ Sort: slack.DEFAULT_SEARCH_SORT, SortDirection: slack.DEFAULT_SEARCH_SORT_DIR, Highlight: false, Count: params.limit, Page: params.page, } messagesRes, _, err := ch.apiProvider.Slack().SearchContext(ctx, params.query, searchParams) if err != nil { ch.logger.Error("Slack SearchContext failed", zap.Error(err)) return nil, err } ch.logger.Debug("Search completed", zap.Int("matches", len(messagesRes.Matches))) messages := ch.convertMessagesFromSearch(messagesRes.Matches) if len(messages) > 0 && ((messagesRes.Pagination.PerPage * messagesRes.Pagination.PageCount) < messagesRes.Pagination.TotalCount) { nextCursor := fmt.Sprintf("page:%d", messagesRes.Pagination.PageCount+1) messages[len(messages)-1].Cursor = base64.StdEncoding.EncodeToString([]byte(nextCursor)) } return marshalMessagesToCSV(messages) } func isChannelAllowed(channel string) bool { config := os.Getenv("SLACK_MCP_ADD_MESSAGE_TOOL") if config == "" || config == "true" || config == "1" { return true } items := strings.Split(config, ",") isNegated := strings.HasPrefix(strings.TrimSpace(items[0]), "!") for _, item := range items { item = strings.TrimSpace(item) if isNegated { if strings.TrimPrefix(item, "!") == channel { return false } } else { if item == channel { return true } } } return !isNegated } func (ch *ConversationsHandler) convertMessagesFromHistory(slackMessages []slack.Message, channel string, includeActivity bool) []Message { usersMap := ch.apiProvider.ProvideUsersMap() var messages []Message warn := false for _, msg := range slackMessages { if (msg.SubType != "" && msg.SubType != "bot_message") && !includeActivity { continue } userName, realName, ok := getUserInfo(msg.User, usersMap.Users) if !ok && msg.SubType == "bot_message" { userName, realName, ok = getBotInfo(msg.Username) } if !ok { warn = true } timestamp, err := text.TimestampToIsoRFC3339(msg.Timestamp) if err != nil { ch.logger.Error("Failed to convert timestamp to RFC3339", zap.Error(err)) continue } msgText := msg.Text + text.AttachmentsTo2CSV(msg.Text, msg.Attachments) var reactionParts []string for _, r := range msg.Reactions { reactionParts = append(reactionParts, fmt.Sprintf("%s:%d", r.Name, r.Count)) } reactionsString := strings.Join(reactionParts, "|") messages = append(messages, Message{ MsgID: msg.Timestamp, UserID: msg.User, UserName: userName, RealName: realName, Text: text.ProcessText(msgText), Channel: channel, ThreadTs: msg.ThreadTimestamp, Time: timestamp, Reactions: reactionsString, }) } if ready, err := ch.apiProvider.IsReady(); !ready { if warn && errors.Is(err, provider.ErrUsersNotReady) { ch.logger.Warn( "WARNING: Slack users sync is not ready yet, you may experience some limited functionality and see UIDs instead of resolved names as well as unable to query users by their @handles. Users sync is part of channels sync and operations on channels depend on users collection (IM, MPIM). Please wait until users are synced and try again", zap.Error(err), ) } } return messages } func (ch *ConversationsHandler) convertMessagesFromSearch(slackMessages []slack.SearchMessage) []Message { usersMap := ch.apiProvider.ProvideUsersMap() var messages []Message warn := false for _, msg := range slackMessages { userName, realName, ok := getUserInfo(msg.User, usersMap.Users) if !ok && msg.User == "" && msg.Username != "" { userName, realName, ok = getBotInfo(msg.Username) } else if !ok { warn = true } threadTs, _ := extractThreadTS(msg.Permalink) timestamp, err := text.TimestampToIsoRFC3339(msg.Timestamp) if err != nil { ch.logger.Error("Failed to convert timestamp to RFC3339", zap.Error(err)) continue } msgText := msg.Text + text.AttachmentsTo2CSV(msg.Text, msg.Attachments) messages = append(messages, Message{ MsgID: msg.Timestamp, UserID: msg.User, UserName: userName, RealName: realName, Text: text.ProcessText(msgText), Channel: fmt.Sprintf("#%s", msg.Channel.Name), ThreadTs: threadTs, Time: timestamp, Reactions: "", }) } if ready, err := ch.apiProvider.IsReady(); !ready { if warn && errors.Is(err, provider.ErrUsersNotReady) { ch.logger.Warn( "Slack users sync not ready; you may see raw UIDs instead of names and lose some functionality.", zap.Error(err), ) } } return messages } func (ch *ConversationsHandler) parseParamsToolConversations(request mcp.CallToolRequest) (*conversationParams, error) { channel := request.GetString("channel_id", "") if channel == "" { ch.logger.Error("channel_id missing in conversations params") return nil, errors.New("channel_id must be a string") } limit := request.GetString("limit", "") cursor := request.GetString("cursor", "") activity := request.GetBool("include_activity_messages", false) var ( paramLimit int paramOldest string paramLatest string err error ) if strings.HasSuffix(limit, "d") || strings.HasSuffix(limit, "w") || strings.HasSuffix(limit, "m") { paramLimit, paramOldest, paramLatest, err = limitByExpression(limit, defaultConversationsExpressionLimit) if err != nil { ch.logger.Error("Invalid duration limit", zap.String("limit", limit), zap.Error(err)) return nil, err } } else if cursor == "" { paramLimit, err = limitByNumeric(limit, defaultConversationsNumericLimit) if err != nil { ch.logger.Error("Invalid numeric limit", zap.String("limit", limit), zap.Error(err)) return nil, err } } if strings.HasPrefix(channel, "#") || strings.HasPrefix(channel, "@") { if ready, err := ch.apiProvider.IsReady(); !ready { if errors.Is(err, provider.ErrUsersNotReady) { ch.logger.Warn( "WARNING: Slack users sync is not ready yet, you may experience some limited functionality and see UIDs instead of resolved names as well as unable to query users by their @handles. Users sync is part of channels sync and operations on channels depend on users collection (IM, MPIM). Please wait until users are synced and try again", zap.Error(err), ) } if errors.Is(err, provider.ErrChannelsNotReady) { ch.logger.Warn( "WARNING: Slack channels sync is not ready yet, you may experience some limited functionality and be able to request conversation only by Channel ID, not by its name. Please wait until channels are synced and try again.", zap.Error(err), ) } return nil, fmt.Errorf("channel %q not found in empty cache", channel) } channelsMaps := ch.apiProvider.ProvideChannelsMaps() chn, ok := channelsMaps.ChannelsInv[channel] if !ok { ch.logger.Error("Channel not found in synced cache", zap.String("channel", channel)) return nil, fmt.Errorf("channel %q not found in synced cache. Try to remove old cache file and restart MCP Server", channel) } channel = channelsMaps.Channels[chn].ID } return &conversationParams{ channel: channel, limit: paramLimit, oldest: paramOldest, latest: paramLatest, cursor: cursor, activity: activity, }, nil } func (ch *ConversationsHandler) parseParamsToolAddMessage(request mcp.CallToolRequest) (*addMessageParams, error) { toolConfig := os.Getenv("SLACK_MCP_ADD_MESSAGE_TOOL") if toolConfig == "" { ch.logger.Error("Add-message tool disabled by default") return nil, errors.New( "by default, the conversations_add_message tool is disabled to guard Slack workspaces against accidental spamming." + "To enable it, set the SLACK_MCP_ADD_MESSAGE_TOOL environment variable to true, 1, or comma separated list of channels" + "to limit where the MCP can post messages, e.g. 'SLACK_MCP_ADD_MESSAGE_TOOL=C1234567890,D0987654321', 'SLACK_MCP_ADD_MESSAGE_TOOL=!C1234567890'" + "to enable all except one or 'SLACK_MCP_ADD_MESSAGE_TOOL=true' for all channels and DMs", ) } channel := request.GetString("channel_id", "") if channel == "" { ch.logger.Error("channel_id missing in add-message params") return nil, errors.New("channel_id must be a string") } 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 } if !isChannelAllowed(channel) { ch.logger.Warn("Add-message tool not allowed for channel", zap.String("channel", channel), zap.String("policy", toolConfig)) return nil, fmt.Errorf("conversations_add_message tool is not allowed for channel %q, applied policy: %s", channel, toolConfig) } threadTs := request.GetString("thread_ts", "") if threadTs != "" && !strings.Contains(threadTs, ".") { ch.logger.Error("Invalid thread_ts format", zap.String("thread_ts", threadTs)) return nil, errors.New("thread_ts must be a valid timestamp in format 1234567890.123456") } msgText := request.GetString("payload", "") if msgText == "" { ch.logger.Error("Message text missing") return nil, errors.New("text must be a string") } contentType := request.GetString("content_type", "text/markdown") if contentType != "text/plain" && contentType != "text/markdown" { ch.logger.Error("Invalid content_type", zap.String("content_type", contentType)) return nil, errors.New("content_type must be either 'text/plain' or 'text/markdown'") } return &addMessageParams{ channel: channel, threadTs: threadTs, text: msgText, contentType: contentType, }, nil } func (ch *ConversationsHandler) parseParamsToolSearch(req mcp.CallToolRequest) (*searchParams, error) { rawQuery := strings.TrimSpace(req.GetString("search_query", "")) freeText, filters := splitQuery(rawQuery) if req.GetBool("filter_threads_only", false) { addFilter(filters, "is", "thread") } if chName := req.GetString("filter_in_channel", ""); chName != "" { f, err := ch.paramFormatChannel(chName) if err != nil { ch.logger.Error("Invalid channel filter", zap.String("filter", chName), zap.Error(err)) return nil, err } addFilter(filters, "in", f) } else if im := req.GetString("filter_in_im_or_mpim", ""); im != "" { f, err := ch.paramFormatUser(im) if err != nil { ch.logger.Error("Invalid IM/MPIM filter", zap.String("filter", im), zap.Error(err)) return nil, err } addFilter(filters, "in", f) } if with := req.GetString("filter_users_with", ""); with != "" { f, err := ch.paramFormatUser(with) if err != nil { ch.logger.Error("Invalid with-user filter", zap.String("filter", with), zap.Error(err)) return nil, err } addFilter(filters, "with", f) } if from := req.GetString("filter_users_from", ""); from != "" { f, err := ch.paramFormatUser(from) if err != nil { ch.logger.Error("Invalid from-user filter", zap.String("filter", from), zap.Error(err)) return nil, err } addFilter(filters, "from", f) } dateMap, err := buildDateFilters( req.GetString("filter_date_before", ""), req.GetString("filter_date_after", ""), req.GetString("filter_date_on", ""), req.GetString("filter_date_during", ""), ) if err != nil { ch.logger.Error("Invalid date filters", zap.Error(err)) return nil, err } for key, val := range dateMap { addFilter(filters, key, val) } finalQuery := buildQuery(freeText, filters) limit := req.GetInt("limit", 100) cursor := req.GetString("cursor", "") var ( page int decodedCursor []byte ) if cursor != "" { decodedCursor, err = base64.StdEncoding.DecodeString(cursor) if err != nil { ch.logger.Error("Invalid cursor decoding", zap.String("cursor", cursor), zap.Error(err)) return nil, fmt.Errorf("invalid cursor: %v", err) } parts := strings.Split(string(decodedCursor), ":") if len(parts) != 2 { ch.logger.Error("Invalid cursor format", zap.String("cursor", cursor)) return nil, fmt.Errorf("invalid cursor: %v", cursor) } page, err = strconv.Atoi(parts[1]) if err != nil || page < 1 { ch.logger.Error("Invalid cursor page", zap.String("cursor", cursor), zap.Error(err)) return nil, fmt.Errorf("invalid cursor page: %v", err) } } else { page = 1 } ch.logger.Debug("Search parameters built", zap.String("query", finalQuery), zap.Int("limit", limit), zap.Int("page", page), ) return &searchParams{ query: finalQuery, limit: limit, page: page, }, nil } func (ch *ConversationsHandler) paramFormatUser(raw string) (string, error) { users := ch.apiProvider.ProvideUsersMap() raw = strings.TrimSpace(raw) if strings.HasPrefix(raw, "U") { u, ok := users.Users[raw] if !ok { return "", fmt.Errorf("user %q not found", raw) } return fmt.Sprintf("<@%s>", u.ID), nil } if strings.HasPrefix(raw, "<@") { raw = raw[2:] } if strings.HasPrefix(raw, "@") { raw = raw[1:] } uid, ok := users.UsersInv[raw] if !ok { return "", fmt.Errorf("user %q not found", raw) } return fmt.Sprintf("<@%s>", uid), nil } func (ch *ConversationsHandler) paramFormatChannel(raw string) (string, error) { raw = strings.TrimSpace(raw) cms := ch.apiProvider.ProvideChannelsMaps() if strings.HasPrefix(raw, "#") { if id, ok := cms.ChannelsInv[raw]; ok { return "#" + cms.Channels[id].Name, nil } return "", fmt.Errorf("channel %q not found", raw) } if strings.HasPrefix(raw, "C") { if chn, ok := cms.Channels[raw]; ok { return "#" + chn.Name, nil } return "", fmt.Errorf("channel %q not found", raw) } return "", fmt.Errorf("invalid channel format: %q", raw) } func marshalMessagesToCSV(messages []Message) (*mcp.CallToolResult, error) { csvBytes, err := gocsv.MarshalBytes(&messages) if err != nil { return nil, err } return mcp.NewToolResultText(string(csvBytes)), nil } func getUserInfo(userID string, usersMap map[string]slack.User) (userName, realName string, ok bool) { if u, ok := usersMap[userID]; ok { return u.Name, u.RealName, true } return userID, userID, false } func getBotInfo(botID string) (userName, realName string, ok bool) { return botID, botID, true } func limitByNumeric(limit string, defaultLimit int) (int, error) { if limit == "" { return defaultLimit, nil } n, err := strconv.Atoi(limit) if err != nil { return 0, fmt.Errorf("invalid numeric limit: %q", limit) } return n, nil } func limitByExpression(limit, defaultLimit string) (slackLimit int, oldest, latest string, err error) { if limit == "" { limit = defaultLimit } if len(limit) < 2 { return 0, "", "", fmt.Errorf("invalid duration limit %q: too short", limit) } suffix := limit[len(limit)-1] numStr := limit[:len(limit)-1] n, err := strconv.Atoi(numStr) if err != nil || n <= 0 { return 0, "", "", fmt.Errorf("invalid duration limit %q: must be a positive integer followed by 'd', 'w', or 'm'", limit) } now := time.Now() loc := now.Location() startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) var oldestTime time.Time switch suffix { case 'd': oldestTime = startOfToday.AddDate(0, 0, -n+1) case 'w': oldestTime = startOfToday.AddDate(0, 0, -n*7+1) case 'm': oldestTime = startOfToday.AddDate(0, -n, 0) default: return 0, "", "", fmt.Errorf("invalid duration limit %q: must end in 'd', 'w', or 'm'", limit) } latest = fmt.Sprintf("%d.000000", now.Unix()) oldest = fmt.Sprintf("%d.000000", oldestTime.Unix()) return 100, oldest, latest, nil } func extractThreadTS(rawurl string) (string, error) { u, err := url.Parse(rawurl) if err != nil { return "", err } return u.Query().Get("thread_ts"), nil } func parseFlexibleDate(dateStr string) (time.Time, string, error) { dateStr = strings.TrimSpace(dateStr) standardFormats := []string{ "2006-01-02", // YYYY-MM-DD "2006/01/02", // YYYY/MM/DD "01-02-2006", // MM-DD-YYYY "01/02/2006", // MM/DD/YYYY "02-01-2006", // DD-MM-YYYY "02/01/2006", // DD/MM/YYYY "Jan 2, 2006", // Jan 2, 2006 "January 2, 2006", // January 2, 2006 "2 Jan 2006", // 2 Jan 2006 "2 January 2006", // 2 January 2006 } for _, fmtStr := range standardFormats { if t, err := time.Parse(fmtStr, dateStr); err == nil { return t, t.Format("2006-01-02"), nil } } monthMap := map[string]int{ "january": 1, "jan": 1, "february": 2, "feb": 2, "march": 3, "mar": 3, "april": 4, "apr": 4, "may": 5, "june": 6, "jun": 6, "july": 7, "jul": 7, "august": 8, "aug": 8, "september": 9, "sep": 9, "sept": 9, "october": 10, "oct": 10, "november": 11, "nov": 11, "december": 12, "dec": 12, } // Month-Year patterns monthYear := regexp.MustCompile(`^(\d{4})\s+([A-Za-z]+)$|^([A-Za-z]+)\s+(\d{4})$`) if m := monthYear.FindStringSubmatch(dateStr); m != nil { var year int var monStr string if m[1] != "" && m[2] != "" { year, _ = strconv.Atoi(m[1]) monStr = strings.ToLower(m[2]) } else { year, _ = strconv.Atoi(m[4]) monStr = strings.ToLower(m[3]) } if mon, ok := monthMap[monStr]; ok { t := time.Date(year, time.Month(mon), 1, 0, 0, 0, 0, time.UTC) return t, t.Format("2006-01-02"), nil } } // Day-Month-Year and Month-Day-Year patterns dmy1 := regexp.MustCompile(`^(\d{1,2})[-\s]+([A-Za-z]+)[-\s]+(\d{4})$`) if m := dmy1.FindStringSubmatch(dateStr); m != nil { day, _ := strconv.Atoi(m[1]) year, _ := strconv.Atoi(m[3]) monStr := strings.ToLower(m[2]) if mon, ok := monthMap[monStr]; ok { t := time.Date(year, time.Month(mon), day, 0, 0, 0, 0, time.UTC) if t.Day() == day { return t, t.Format("2006-01-02"), nil } } } mdy := regexp.MustCompile(`^([A-Za-z]+)[-\s]+(\d{1,2})[-\s]+(\d{4})$`) if m := mdy.FindStringSubmatch(dateStr); m != nil { monStr := strings.ToLower(m[1]) day, _ := strconv.Atoi(m[2]) year, _ := strconv.Atoi(m[3]) if mon, ok := monthMap[monStr]; ok { t := time.Date(year, time.Month(mon), day, 0, 0, 0, 0, time.UTC) if t.Day() == day { return t, t.Format("2006-01-02"), nil } } } ymd := regexp.MustCompile(`^(\d{4})[-\s]+([A-Za-z]+)[-\s]+(\d{1,2})$`) if m := ymd.FindStringSubmatch(dateStr); m != nil { year, _ := strconv.Atoi(m[1]) monStr := strings.ToLower(m[2]) day, _ := strconv.Atoi(m[3]) if mon, ok := monthMap[monStr]; ok { t := time.Date(year, time.Month(mon), day, 0, 0, 0, 0, time.UTC) if t.Day() == day { return t, t.Format("2006-01-02"), nil } } } lower := strings.ToLower(dateStr) now := time.Now().UTC() switch lower { case "today": t := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) return t, t.Format("2006-01-02"), nil case "yesterday": t := now.AddDate(0, 0, -1) t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) return t, t.Format("2006-01-02"), nil case "tomorrow": t := now.AddDate(0, 0, 1) t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) return t, t.Format("2006-01-02"), nil } daysAgo := regexp.MustCompile(`^(\d+)\s+days?\s+ago$`) if m := daysAgo.FindStringSubmatch(lower); m != nil { days, _ := strconv.Atoi(m[1]) t := now.AddDate(0, 0, -days) t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) return t, t.Format("2006-01-02"), nil } return time.Time{}, "", fmt.Errorf("unable to parse date: %s", dateStr) } func buildDateFilters(before, after, on, during string) (map[string]string, error) { out := make(map[string]string) if on != "" { if during != "" || before != "" || after != "" { return nil, fmt.Errorf("'on' cannot be combined with other date filters") } _, normalized, err := parseFlexibleDate(on) if err != nil { return nil, fmt.Errorf("invalid 'on' date: %v", err) } out["on"] = normalized return out, nil } if during != "" { if before != "" || after != "" { return nil, fmt.Errorf("'during' cannot be combined with 'before' or 'after'") } _, normalized, err := parseFlexibleDate(during) if err != nil { return nil, fmt.Errorf("invalid 'during' date: %v", err) } out["during"] = normalized return out, nil } if after != "" { _, normalized, err := parseFlexibleDate(after) if err != nil { return nil, fmt.Errorf("invalid 'after' date: %v", err) } out["after"] = normalized } if before != "" { _, normalized, err := parseFlexibleDate(before) if err != nil { return nil, fmt.Errorf("invalid 'before' date: %v", err) } out["before"] = normalized } if after != "" && before != "" { a, _, _ := parseFlexibleDate(after) b, _, _ := parseFlexibleDate(before) if a.After(b) { return nil, fmt.Errorf("'after' date is after 'before' date") } } return out, nil } func isFilterKey(key string) bool { _, ok := validFilterKeys[strings.ToLower(key)] return ok } func splitQuery(q string) (freeText []string, filters map[string][]string) { filters = make(map[string][]string) for _, tok := range strings.Fields(q) { parts := strings.SplitN(tok, ":", 2) if len(parts) == 2 && isFilterKey(parts[0]) { key := strings.ToLower(parts[0]) filters[key] = append(filters[key], parts[1]) } else { freeText = append(freeText, tok) } } return } func addFilter(filters map[string][]string, key, val string) { for _, existing := range filters[key] { if existing == val { return } } filters[key] = append(filters[key], val) } func buildQuery(freeText []string, filters map[string][]string) string { var out []string out = append(out, freeText...) for _, key := range []string{"is", "in", "from", "with", "before", "after", "on", "during"} { for _, val := range filters[key] { out = append(out, fmt.Sprintf("%s:%s", key, val)) } } return strings.Join(out, " ") }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/korotovsky/slack-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server